diff --git a/android/app/src/main/kotlin/com/rousoftware/codex_remote/CodexForegroundService.kt b/android/app/src/main/kotlin/com/rousoftware/codex_remote/CodexForegroundService.kt index 0b5a490..4d76286 100644 --- a/android/app/src/main/kotlin/com/rousoftware/codex_remote/CodexForegroundService.kt +++ b/android/app/src/main/kotlin/com/rousoftware/codex_remote/CodexForegroundService.kt @@ -18,6 +18,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.io.File import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit @@ -85,6 +86,12 @@ class CodexForegroundService : Service() { sendOrQueue(payload) } } + ACTION_SEND_FILE -> { + val path = intent.getStringExtra(EXTRA_PAYLOAD_FILE) + if (!path.isNullOrBlank()) { + sendPayloadFile(path) + } + } ACTION_DISCONNECT -> { disconnect(stopService = true) } @@ -201,6 +208,29 @@ class CodexForegroundService : Service() { } } + private fun sendPayloadFile(path: String) { + val file = File(path) + if (!file.exists()) { + CodexServiceBridge.pushEvent( + """{"method":"android/transportStatus","params":{"status":"error","message":"Payload file missing."}}""" + ) + return + } + val payload = try { + file.readText() + } catch (t: Throwable) { + CodexServiceBridge.pushEvent( + """{"method":"android/transportStatus","params":{"status":"error","message":${(t.message ?: "Failed to read payload file").quoteJson()}}}""" + ) + return + } finally { + file.delete() + } + if (payload.isNotBlank()) { + sendOrQueue(payload) + } + } + private fun flushPendingMessages(webSocket: WebSocket) { while (socket === webSocket && isSocketOpen) { val payload = pendingMessages.poll() ?: break @@ -275,10 +305,12 @@ class CodexForegroundService : Service() { private const val NOTIFICATION_ID = 31041 const val ACTION_CONNECT = "codex_remote.action.CONNECT" const val ACTION_SEND = "codex_remote.action.SEND" + const val ACTION_SEND_FILE = "codex_remote.action.SEND_FILE" const val ACTION_DISCONNECT = "codex_remote.action.DISCONNECT" const val EXTRA_URL = "url" const val EXTRA_BEARER_TOKEN = "bearerToken" const val EXTRA_PAYLOAD = "payload" + const val EXTRA_PAYLOAD_FILE = "payloadFile" fun startService(context: Context, intent: Intent) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/android/app/src/main/kotlin/com/rousoftware/codex_remote/MainActivity.kt b/android/app/src/main/kotlin/com/rousoftware/codex_remote/MainActivity.kt index e29ab9c..ca113a5 100644 --- a/android/app/src/main/kotlin/com/rousoftware/codex_remote/MainActivity.kt +++ b/android/app/src/main/kotlin/com/rousoftware/codex_remote/MainActivity.kt @@ -5,6 +5,7 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel +import java.io.File class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -45,6 +46,25 @@ class MainActivity : FlutterActivity() { result.success(null) } + "sendFile" -> { + val path = call.argument("path") + if (path.isNullOrBlank()) { + result.error("invalid_args", "Missing payload file path", null) + return@setMethodCallHandler + } + val file = File(path) + if (!file.exists()) { + result.error("invalid_args", "Payload file does not exist", null) + return@setMethodCallHandler + } + val intent = Intent(this, CodexForegroundService::class.java).apply { + action = CodexForegroundService.ACTION_SEND_FILE + putExtra(CodexForegroundService.EXTRA_PAYLOAD_FILE, path) + } + CodexForegroundService.startService(this, intent) + result.success(null) + } + "disconnect" -> { val intent = Intent(this, CodexForegroundService::class.java).apply { action = CodexForegroundService.ACTION_DISCONNECT diff --git a/lib/main.dart b/lib/main.dart index 6467a16..c6a9c67 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,19 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'src/app.dart'; -import 'src/app_controller.dart'; +import 'src/app/bootstrap/app_bootstrap.dart'; +import 'src/app/providers.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - final controller = await AppController.bootstrap(); - runApp(CodexRemoteApp(controller: controller)); + final controller = await AppBootstrap.load(); + runApp( + ProviderScope( + overrides: [ + appControllerProvider.overrideWith((Ref ref) => controller), + ], + child: const CodexRemoteApp(), + ), + ); } diff --git a/lib/src/app.dart b/lib/src/app.dart index 164f3c4..da07666 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,199 +1 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -import 'app_controller.dart'; -import 'home_page.dart'; - -class CodexRemoteApp extends StatelessWidget { - const CodexRemoteApp({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller, - builder: (BuildContext context, Widget? child) { - return MaterialApp( - title: 'Codex Remote', - debugShowCheckedModeBanner: false, - themeMode: controller.settings.materialThemeMode, - theme: _buildLightTheme(), - darkTheme: _buildDarkTheme(), - home: HomePage(controller: controller), - ); - }, - ); - } -} - -ThemeData _buildLightTheme() { - const background = Color(0xFFFAF8F5); - const surface = Color(0xFFFFFFFF); - const primary = Color(0xFFB45309); - const secondary = Color(0xFFD97706); - const accent = Color(0xFF059669); - const text = Color(0xFF451A03); - const muted = Color(0xFF9A7B63); - const border = Color(0xFFE7DED5); - - final scheme = const ColorScheme( - brightness: Brightness.light, - primary: primary, - onPrimary: Colors.white, - secondary: secondary, - onSecondary: Colors.white, - error: Color(0xFFB42318), - onError: Colors.white, - surface: surface, - onSurface: text, - ); - - return _buildTheme( - scheme: scheme, - background: background, - muted: muted, - border: border, - accent: accent, - ); -} - -ThemeData _buildDarkTheme() { - const background = Color(0xFF0F0F0F); - const surface = Color(0xFF1A1A1A); - const primary = Color(0xFF00D4AA); - const secondary = Color(0xFF00A3CC); - const accent = Color(0xFFFF6B9D); - const text = Color(0xFFF5F5F5); - const muted = Color(0xFF9D9D9D); - const border = Color(0xFF2A2A2A); - - final scheme = const ColorScheme( - brightness: Brightness.dark, - primary: primary, - onPrimary: Color(0xFF07110F), - secondary: secondary, - onSecondary: Color(0xFF071217), - error: Color(0xFFFF8A8A), - onError: Color(0xFF2A0608), - surface: surface, - onSurface: text, - ); - - return _buildTheme( - scheme: scheme, - background: background, - muted: muted, - border: border, - accent: accent, - ); -} - -ThemeData _buildTheme({ - required ColorScheme scheme, - required Color background, - required Color muted, - required Color border, - required Color accent, -}) { - final base = ThemeData( - useMaterial3: true, - colorScheme: scheme, - scaffoldBackgroundColor: background, - ); - final textTheme = GoogleFonts.ibmPlexSansTextTheme(base.textTheme).copyWith( - titleLarge: GoogleFonts.ibmPlexSans( - fontSize: 18, - fontWeight: FontWeight.w600, - color: scheme.onSurface, - ), - titleMedium: GoogleFonts.ibmPlexSans( - fontSize: 15, - fontWeight: FontWeight.w600, - color: scheme.onSurface, - ), - bodyLarge: GoogleFonts.ibmPlexSans( - fontSize: 15, - height: 1.45, - color: scheme.onSurface, - ), - bodyMedium: GoogleFonts.ibmPlexSans( - fontSize: 14, - height: 1.45, - color: scheme.onSurface, - ), - bodySmall: GoogleFonts.ibmPlexSans( - fontSize: 13, - height: 1.35, - color: muted, - ), - labelLarge: GoogleFonts.ibmPlexSans( - fontSize: 14, - fontWeight: FontWeight.w600, - color: scheme.onSurface, - ), - ); - - return base.copyWith( - textTheme: textTheme, - appBarTheme: AppBarTheme( - elevation: 0, - scrolledUnderElevation: 0, - backgroundColor: background, - foregroundColor: scheme.onSurface, - surfaceTintColor: Colors.transparent, - titleTextStyle: textTheme.titleLarge, - ), - dividerColor: border, - cardTheme: CardThemeData( - elevation: 0, - color: scheme.surface, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide(color: border), - ), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: scheme.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: scheme.primary), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - elevation: 0, - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - side: BorderSide(color: border), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - foregroundColor: scheme.onSurface, - ), - ), - chipTheme: base.chipTheme.copyWith( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - side: BorderSide(color: border), - backgroundColor: scheme.surface, - selectedColor: accent.withValues(alpha: 0.16), - labelStyle: textTheme.bodySmall?.copyWith(color: scheme.onSurface), - ), - ); -} +export 'app/app.dart'; diff --git a/lib/src/app/app.dart b/lib/src/app/app.dart new file mode 100644 index 0000000..3ec1fea --- /dev/null +++ b/lib/src/app/app.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app_controller.dart'; +import '../home_page.dart'; +import 'providers.dart'; +import 'theme/app_theme.dart'; + +class CodexRemoteApp extends StatelessWidget { + const CodexRemoteApp({super.key, this.controller}); + + final AppController? controller; + + @override + Widget build(BuildContext context) { + if (controller == null) { + return const _CodexRemoteAppView(); + } + return ProviderScope( + overrides: [ + appControllerProvider.overrideWith((Ref ref) => controller!), + ], + child: const _CodexRemoteAppView(), + ); + } +} + +class _CodexRemoteAppView extends ConsumerWidget { + const _CodexRemoteAppView(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.watch(appControllerProvider); + return MaterialApp( + title: 'Codex Remote', + debugShowCheckedModeBanner: false, + themeMode: controller.settings.materialThemeMode, + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + home: HomePage(controller: controller), + ); + } +} diff --git a/lib/src/app/bootstrap/app_bootstrap.dart b/lib/src/app/bootstrap/app_bootstrap.dart new file mode 100644 index 0000000..d34f527 --- /dev/null +++ b/lib/src/app/bootstrap/app_bootstrap.dart @@ -0,0 +1,9 @@ +import '../../app_controller.dart'; + +class AppBootstrap { + const AppBootstrap._(); + + static Future load() { + return AppController.bootstrap(); + } +} diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart new file mode 100644 index 0000000..2a676aa --- /dev/null +++ b/lib/src/app/providers.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app_controller.dart'; + +final appControllerProvider = ChangeNotifierProvider((ref) { + throw UnimplementedError( + 'appControllerProvider must be overridden during bootstrap.', + ); +}); diff --git a/lib/src/app/theme/app_theme.dart b/lib/src/app/theme/app_theme.dart new file mode 100644 index 0000000..3b3caa3 --- /dev/null +++ b/lib/src/app/theme/app_theme.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +ThemeData buildLightTheme() { + const background = Color(0xFFFAF8F5); + const surface = Color(0xFFFFFFFF); + const primary = Color(0xFFB45309); + const secondary = Color(0xFFD97706); + const accent = Color(0xFF059669); + const text = Color(0xFF451A03); + const muted = Color(0xFF9A7B63); + const border = Color(0xFFE7DED5); + + final scheme = const ColorScheme( + brightness: Brightness.light, + primary: primary, + onPrimary: Colors.white, + secondary: secondary, + onSecondary: Colors.white, + error: Color(0xFFB42318), + onError: Colors.white, + surface: surface, + onSurface: text, + ); + + return _buildTheme( + scheme: scheme, + background: background, + muted: muted, + border: border, + accent: accent, + ); +} + +ThemeData buildDarkTheme() { + const background = Color(0xFF0F0F0F); + const surface = Color(0xFF1A1A1A); + const primary = Color(0xFF00D4AA); + const secondary = Color(0xFF00A3CC); + const accent = Color(0xFFFF6B9D); + const text = Color(0xFFF5F5F5); + const muted = Color(0xFF9D9D9D); + const border = Color(0xFF2A2A2A); + + final scheme = const ColorScheme( + brightness: Brightness.dark, + primary: primary, + onPrimary: Color(0xFF07110F), + secondary: secondary, + onSecondary: Color(0xFF071217), + error: Color(0xFFFF8A8A), + onError: Color(0xFF2A0608), + surface: surface, + onSurface: text, + ); + + return _buildTheme( + scheme: scheme, + background: background, + muted: muted, + border: border, + accent: accent, + ); +} + +ThemeData _buildTheme({ + required ColorScheme scheme, + required Color background, + required Color muted, + required Color border, + required Color accent, +}) { + final base = ThemeData( + useMaterial3: false, + colorScheme: scheme, + scaffoldBackgroundColor: background, + ); + final textTheme = GoogleFonts.ibmPlexSansTextTheme(base.textTheme).copyWith( + titleLarge: GoogleFonts.ibmPlexSans( + fontSize: 18, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + titleMedium: GoogleFonts.ibmPlexSans( + fontSize: 15, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + bodyLarge: GoogleFonts.ibmPlexSans( + fontSize: 15, + height: 1.45, + color: scheme.onSurface, + ), + bodyMedium: GoogleFonts.ibmPlexSans( + fontSize: 14, + height: 1.45, + color: scheme.onSurface, + ), + bodySmall: GoogleFonts.ibmPlexSans( + fontSize: 13, + height: 1.35, + color: muted, + ), + labelLarge: GoogleFonts.ibmPlexSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ); + + return base.copyWith( + splashFactory: InkRipple.splashFactory, + textTheme: textTheme, + appBarTheme: AppBarTheme( + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: background, + foregroundColor: scheme.onSurface, + surfaceTintColor: Colors.transparent, + titleTextStyle: textTheme.titleLarge, + ), + dividerColor: border, + cardTheme: CardThemeData( + elevation: 0, + color: scheme.surface, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: border), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: scheme.primary), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + side: BorderSide(color: border), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + foregroundColor: scheme.onSurface, + ), + ), + chipTheme: base.chipTheme.copyWith( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: BorderSide(color: border), + backgroundColor: scheme.surface, + selectedColor: accent.withValues(alpha: 0.16), + labelStyle: textTheme.bodySmall?.copyWith(color: scheme.onSurface), + ), + ); +} diff --git a/lib/src/app_controller.dart b/lib/src/app_controller.dart index 901c515..1807699 100644 --- a/lib/src/app_controller.dart +++ b/lib/src/app_controller.dart @@ -1,4992 +1 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:cryptography/cryptography.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/widgets.dart'; -import 'package:open_filex/open_filex.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import 'models.dart'; -import 'settings_store.dart'; -import 'transport.dart'; - -class AppController extends ChangeNotifier with WidgetsBindingObserver { - static const Duration _directoryReadTimeout = Duration(minutes: 2); - - AppController._( - this._settingsStore, - this._settings, - this._transport, - this._httpClientFactory, - this._openPath, - this._automationFsQuietPeriod, - ) { - automations.addAll(_settings.automations); - } - - static Future bootstrap() async { - final store = SettingsStore(); - final settings = await store.load(); - final recentCommands = await store.loadRecentCommands(); - final controller = AppController._( - store, - settings, - createDefaultTransport(), - () => HttpClient(), - _defaultOpenPath, - const Duration(milliseconds: 1500), - ); - controller.recentCommands.addAll(recentCommands); - WidgetsBinding.instance.addObserver(controller); - return controller; - } - - @visibleForTesting - factory AppController.testing({ - AppTransport? transport, - HttpClient Function()? httpClientFactory, - Future Function(String path)? openPath, - Duration automationFsQuietPeriod = const Duration(milliseconds: 20), - }) { - return AppController._( - SettingsStore(), - AppSettings.defaults(), - transport ?? DirectWebSocketTransport(), - httpClientFactory ?? (() => HttpClient()), - openPath ?? _defaultOpenPath, - automationFsQuietPeriod, - ); - } - - final SettingsStore _settingsStore; - final AppTransport _transport; - final HttpClient Function() _httpClientFactory; - final Future Function(String path) _openPath; - final Duration _automationFsQuietPeriod; - AppSettings _settings; - - AppSettings get settings => _settings; - - ConnectionStatus status = ConnectionStatus.disconnected; - String statusMessage = 'Disconnected'; - String? activeThreadId; - String? activeThreadName; - String activeThreadCwd = ''; - String? _subscribedThreadId; - String? activeTurnId; - final Map _activeTurnIdsByThread = {}; - bool isLoadingHistory = false; - String? openingThreadId; - bool isLoadingFiles = false; - bool isLoadingFilePreview = false; - bool isSavingFilePreview = false; - bool isLoadingModels = false; - String? threadHistoryError; - String? fileBrowserError; - String? filePreviewSaveError; - String? modelListError; - String? rateLimitSummary; - List rateLimitResetDetails = const []; - String? contextWindowSummary; - int? contextUsagePercent; - String? _threadHistoryCursor; - String? activeCommandSessionId; - bool isSteering = false; - String fileBrowserPath = ''; - String? selectedFilePath; - String? selectedFileContent; - Uint8List? selectedFileBytes; - bool selectedFileIsHumanReadable = false; - int? selectedFileHighlightedLine; - final Map _fileDownloadStatusByPath = - {}; - final Map _fileDownloadProcessIdByPath = {}; - final Map _directoryCache = - {}; - final Map _filePreviewCache = - {}; - DateTime? _threadHistoryLoadedAt; - DateTime? _modelOptionsLoadedAt; - - final List entries = []; - final List approvals = []; - final List eventLog = []; - final List threadHistory = []; - final List fileBrowserEntries = []; - final List modelOptions = []; - final List automations = []; - final List commandSessions = []; - final List recentCommands = []; - final List pendingPrompts = []; - final List downloadRecords = []; - - final Map _entryByItemId = {}; - final List _pendingOptimisticUserEntryKeys = []; - final Map _commandSessionsById = - {}; - final Map _commandSessionsByProcessId = - {}; - final Map _pendingDownloadsByProcessId = - {}; - final Map _pendingTransferServersByProcessId = - {}; - final Map _activeAutomationWatches = - {}; - final Map _registeredAutomationWatches = - {}; - final Map _pendingCommandRequestsById = - {}; - final Map?>> _pendingRequests = - ?>>{}; - - StreamSubscription? _subscription; - int _requestId = 1; - bool _manualDisconnect = false; - bool _shouldReconnectOnResume = false; - bool _isInBackground = false; - String? _pendingNewThreadCwd; - final Set _runningAutomationIds = {}; - final Map> _queuedAutomationChangedPaths = - >{}; - final Map _automationDebounceTimers = {}; - final Map> _debouncedAutomationChangedPaths = - >{}; - Completer? _automationWatchSyncCompleter; - bool _automationWatchSyncQueued = false; - - bool get isConnected => status == ConnectionStatus.ready; - bool get hasActiveTurn => activeTurnId != null; - bool get hasMoreThreadHistory => _threadHistoryCursor != null; - bool get isOpeningThread => openingThreadId != null; - int get queuedPromptCount => pendingPrompts.length; - List get queuedPrompts => - pendingPrompts.map((item) => item.text).toList(); - String? get composerMetaLeftText { - final value = rateLimitSummary?.trim() ?? ''; - return value.isEmpty ? null : value; - } - - bool get hasRateLimitResetDetails => rateLimitResetDetails.isNotEmpty; - - String? get composerMetaRightText { - final value = contextWindowSummary?.trim() ?? ''; - return value.isEmpty ? null : value; - } - - String get preferredCommandCwd { - final threadCwd = activeThreadCwd.trim(); - if (threadCwd.isNotEmpty) { - return threadCwd; - } - return _pendingNewThreadCwd?.trim() ?? ''; - } - - String get preferredFileBrowserRoot { - final commandCwd = preferredCommandCwd; - if (commandCwd.isNotEmpty) { - return commandCwd; - } - return '/'; - } - - bool get needsThreadDirectorySelection { - return activeThreadId == null && - _settings.resumeThreadId.trim().isEmpty && - (_pendingNewThreadCwd?.trim().isEmpty ?? true); - } - - String? _preferredDownloadDirectoryForThread(String threadId) { - if (threadId.trim().isEmpty) { - return null; - } - final value = - _settings.threadDownloadDirectories[threadId.trim()]?.trim() ?? ''; - return value.isEmpty ? null : value; - } - - Future _rememberDownloadDirectoryForThread( - String threadId, - String directory, - ) async { - final normalizedThreadId = threadId.trim(); - final normalizedDirectory = directory.trim(); - if (normalizedThreadId.isEmpty || normalizedDirectory.isEmpty) { - return; - } - final nextDirectories = Map.from( - _settings.threadDownloadDirectories, - ); - nextDirectories[normalizedThreadId] = normalizedDirectory; - await saveSettings( - _settings.copyWith(threadDownloadDirectories: nextDirectories), - ); - } - - bool isFileDownloading(String path) { - final normalizedPath = _normalizeAbsolutePath(path); - return _fileDownloadStatusByPath.containsKey(normalizedPath); - } - - double? fileDownloadProgress(String path) { - final normalizedPath = _normalizeAbsolutePath(path); - return _fileDownloadStatusByPath[normalizedPath]?.progress; - } - - FileDownloadStatus? fileDownloadStatus(String path) { - final normalizedPath = _normalizeAbsolutePath(path); - return _fileDownloadStatusByPath[normalizedPath]; - } - - int get activeDownloadCount => downloadRecords - .where((item) => item.state == DownloadState.running) - .length; - - bool get hasDownloads => downloadRecords.isNotEmpty; - bool threadHasActiveTurn(String threadId) { - final normalized = threadId.trim(); - if (normalized.isEmpty) { - return false; - } - return _activeTurnIdsByThread.containsKey(normalized); - } - - bool isAutomationRunning(String automationId) { - return _runningAutomationIds.contains(automationId); - } - - bool isThreadFavorite(String threadId) { - return _settings.favoriteThreadIds.contains(threadId.trim()); - } - - String get currentAutomationScopeThreadId { - final active = activeThreadId?.trim() ?? ''; - if (active.isNotEmpty) { - return active; - } - return _settings.resumeThreadId.trim(); - } - - bool isAutomationVisibleInCurrentThread(AutomationDefinition automation) { - final ownerThreadId = automation.ownerThreadId.trim(); - if (ownerThreadId.isEmpty) { - return true; - } - final scopeThreadId = currentAutomationScopeThreadId; - if (scopeThreadId.isEmpty) { - return false; - } - return ownerThreadId == scopeThreadId; - } - - void _resyncAutomationWatchesForCurrentThread() { - if (isConnected) { - unawaited(_syncAutomationWatches()); - } - } - - Future toggleFavoriteThread(String threadId) async { - final normalizedThreadId = threadId.trim(); - if (normalizedThreadId.isEmpty) { - return; - } - final nextFavorites = List.from(_settings.favoriteThreadIds); - if (nextFavorites.contains(normalizedThreadId)) { - nextFavorites.removeWhere((item) => item == normalizedThreadId); - } else { - nextFavorites.insert(0, normalizedThreadId); - } - await saveSettings(_settings.copyWith(favoriteThreadIds: nextFavorites)); - _sortThreadHistory(); - notifyListeners(); - } - - Future saveSettings(AppSettings nextSettings) async { - final previousModel = _settings.model.trim(); - final previousAutomations = jsonEncode( - _settings.automations - .map((item) => item.toJson()) - .toList(growable: false), - ); - _settings = nextSettings; - automations - ..clear() - ..addAll(_settings.automations); - await _settingsStore.save(_settings); - notifyListeners(); - final nextModel = nextSettings.model.trim(); - final nextAutomations = jsonEncode( - _settings.automations - .map((item) => item.toJson()) - .toList(growable: false), - ); - if (isConnected && previousModel != nextModel) { - unawaited(_refreshUsageMetadata()); - } - if (isConnected && previousAutomations != nextAutomations) { - unawaited(_syncAutomationWatches()); - } - } - - Future clearThreadState() async { - final previousThreadId = activeThreadId?.trim() ?? ''; - if (previousThreadId.isNotEmpty) { - await _unsubscribeFromThread(previousThreadId); - } - activeThreadId = null; - activeThreadName = null; - activeThreadCwd = ''; - activeTurnId = null; - _activeTurnIdsByThread.clear(); - contextUsagePercent = null; - isSteering = false; - entries.clear(); - approvals.clear(); - _entryByItemId.clear(); - _pendingOptimisticUserEntryKeys.clear(); - pendingPrompts.clear(); - _pendingNewThreadCwd = null; - await saveSettings(_settings.copyWith(resumeThreadId: '')); - _addSystemEntry( - 'Started a new local session. The next prompt will open a new thread.', - ); - } - - Future connect() async { - await disconnect(clearUiState: false, manual: false); - _manualDisconnect = false; - status = ConnectionStatus.connecting; - statusMessage = 'Connecting'; - notifyListeners(); - - try { - _subscription = _transport.messages.listen( - _handleSocketMessage, - onError: (Object error, StackTrace stackTrace) { - status = ConnectionStatus.error; - statusMessage = 'Connection error'; - _shouldReconnectOnResume = !_manualDisconnect; - _addSystemEntry('Websocket error: $error'); - notifyListeners(); - }, - onDone: () { - if (status != ConnectionStatus.disconnected) { - status = ConnectionStatus.disconnected; - statusMessage = 'Disconnected'; - activeTurnId = null; - _shouldReconnectOnResume = !_manualDisconnect; - notifyListeners(); - } - }, - ); - await _transport.connect(_settings); - - status = ConnectionStatus.initializing; - statusMessage = 'Initializing'; - notifyListeners(); - - await _request('initialize', { - 'clientInfo': { - 'name': 'codex_remote_flutter', - 'title': 'Codex Remote', - 'version': '1.0.0', - }, - }); - - _notify('initialized'); - status = ConnectionStatus.ready; - statusMessage = 'Ready'; - _shouldReconnectOnResume = true; - _addSystemEntry('Connected to ${_settings.activeConnectionLabel}.'); - await _refreshUsageMetadata(notify: false); - await loadModelOptions(force: true); - await _syncAutomationWatches(); - notifyListeners(); - } catch (error) { - status = ConnectionStatus.error; - statusMessage = 'Failed to connect'; - _shouldReconnectOnResume = !_manualDisconnect; - _addSystemEntry('Connection failed: $error'); - notifyListeners(); - } - } - - Future disconnect({ - bool clearUiState = false, - bool manual = true, - }) async { - _manualDisconnect = manual; - if (manual) { - _shouldReconnectOnResume = false; - } - await _subscription?.cancel(); - _subscription = null; - await _transport.disconnect(); - for (final completer in _pendingRequests.values) { - if (!completer.isCompleted) { - completer.completeError(StateError('Connection closed')); - } - } - _pendingRequests.clear(); - status = ConnectionStatus.disconnected; - statusMessage = 'Disconnected'; - activeTurnId = null; - _activeTurnIdsByThread.clear(); - openingThreadId = null; - _subscribedThreadId = null; - rateLimitSummary = null; - contextWindowSummary = null; - contextUsagePercent = null; - _activeAutomationWatches.clear(); - _registeredAutomationWatches.clear(); - _runningAutomationIds.clear(); - _queuedAutomationChangedPaths.clear(); - if (clearUiState) { - activeThreadName = null; - activeThreadCwd = ''; - entries.clear(); - approvals.clear(); - _entryByItemId.clear(); - _pendingOptimisticUserEntryKeys.clear(); - } - notifyListeners(); - } - - Future reconnectWithSettings(AppSettings nextSettings) async { - final modeChanged = nextSettings.connectionMode != _settings.connectionMode; - final urlChanged = nextSettings.serverUrl != _settings.serverUrl; - final authChanged = - nextSettings.websocketBearerToken.trim() != - _settings.websocketBearerToken.trim(); - final relayChanged = - nextSettings.relayUrl.trim() != _settings.relayUrl.trim() || - nextSettings.relayDeviceId.trim() != _settings.relayDeviceId.trim() || - nextSettings.relayClientPrivateKey.trim() != - _settings.relayClientPrivateKey.trim() || - nextSettings.relayClientPublicKey.trim() != - _settings.relayClientPublicKey.trim() || - nextSettings.relayBridgeSigningPublicKey.trim() != - _settings.relayBridgeSigningPublicKey.trim(); - await saveSettings(nextSettings); - if ((modeChanged || urlChanged || authChanged || relayChanged) && - status != ConnectionStatus.disconnected) { - await connect(); - } - } - - Future pairRelayDevice({ - required String pairingCode, - String clientLabel = 'Codex Remote', - }) async { - final decoded = _decodeRelayPairingCode(pairingCode); - final signing = Ed25519(); - final keyPair = await signing.newKeyPair(); - final keyPairData = await keyPair.extract(); - final publicKey = await keyPair.extractPublicKey(); - final request = await _httpClientFactory().postUrl( - Uri.parse('${decoded.relayUrl}/api/v1/device/claim'), - ); - request.headers.contentType = ContentType.json; - request.write( - jsonEncode({ - 'pairingCode': pairingCode.trim(), - 'clientLabel': clientLabel.trim().isEmpty - ? 'Codex Remote' - : clientLabel.trim(), - 'clientSigningPublicKey': _b64urlEncode(publicKey.bytes), - }), - ); - final response = await request.close(); - final responseBody = await utf8.decodeStream(response); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw StateError( - 'Relay pairing failed: ${response.statusCode} $responseBody', - ); - } - final payload = jsonDecode(responseBody) as Map; - await saveSettings( - _settings.copyWith( - connectionMode: ConnectionMode.relay, - relayUrl: decoded.relayUrl, - relayDeviceId: decoded.deviceId, - relayBridgeLabel: - payload['bridgeLabel']?.toString() ?? decoded.bridgeLabel, - relayBridgeSigningPublicKey: - payload['bridgeSigningPublicKey']?.toString() ?? - decoded.bridgeSigningPublicKey, - relayClientPrivateKey: _b64urlEncode(keyPairData.bytes), - relayClientPublicKey: _b64urlEncode(publicKey.bytes), - ), - ); - } - - Future clearRelayPairing() async { - await saveSettings( - _settings.copyWith( - connectionMode: ConnectionMode.direct, - relayUrl: '', - relayDeviceId: '', - relayBridgeLabel: '', - relayBridgeSigningPublicKey: '', - relayClientPrivateKey: '', - relayClientPublicKey: '', - ), - ); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - final shouldReconnect = - _isInBackground && - _shouldReconnectOnResume && - !_manualDisconnect && - !_transport.isConnected && - (status == ConnectionStatus.disconnected || - status == ConnectionStatus.error); - _isInBackground = false; - if (shouldReconnect) { - unawaited(_reconnectAfterResume()); - } - case AppLifecycleState.inactive: - case AppLifecycleState.hidden: - case AppLifecycleState.paused: - _isInBackground = true; - case AppLifecycleState.detached: - _isInBackground = false; - } - } - - Future _reconnectAfterResume() async { - if (_transport.isConnected) { - if (status != ConnectionStatus.ready) { - status = ConnectionStatus.ready; - statusMessage = 'Ready'; - notifyListeners(); - } - return; - } - await connect(); - if (!isConnected) { - return; - } - - final threadId = activeThreadId ?? _settings.resumeThreadId.trim(); - if (threadId.isEmpty) { - return; - } - - try { - final response = await _request('thread/resume', { - 'threadId': threadId, - }); - final thread = response?['thread']; - if (thread is Map) { - activeThreadId = thread['id']?.toString() ?? threadId; - _subscribedThreadId = activeThreadId; - activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; - activeThreadName = thread['name']?.toString() ?? activeThreadName; - await saveSettings( - _settings.copyWith(resumeThreadId: activeThreadId ?? threadId), - ); - _resyncAutomationWatchesForCurrentThread(); - } - _addSystemEntry('Reconnected after returning to the app.'); - } catch (error) { - _addSystemEntry('Reconnect after resume failed: $error'); - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - Future startFreshThreadInDirectory(String cwd) async { - await clearThreadState(); - _pendingNewThreadCwd = cwd.trim(); - if (isConnected) { - await _ensureThread(forceNew: true); - } - } - - Future saveAutomation(AutomationDefinition automation) async { - final next = List.from(automations); - final scopeThreadId = currentAutomationScopeThreadId; - final normalizedAutomation = automation.copyWith( - ownerThreadId: automation.ownerThreadId.trim().isEmpty - ? scopeThreadId - : automation.ownerThreadId.trim(), - ); - final index = next.indexWhere((item) => item.id == automation.id); - if (index >= 0) { - next[index] = normalizedAutomation; - } else { - next.insert(0, normalizedAutomation); - } - await saveSettings(_settings.copyWith(automations: next)); - } - - Future copyAutomationToCurrentThread(String automationId) async { - AutomationDefinition? source; - for (final automation in automations) { - if (automation.id == automationId) { - source = automation; - break; - } - } - if (source == null) { - return; - } - final copiedAutomation = source.copyWith( - id: 'automation-${DateTime.now().microsecondsSinceEpoch}', - ownerThreadId: currentAutomationScopeThreadId, - nodes: source.nodes - .map( - (node) => node.copyWith( - id: 'node-${DateTime.now().microsecondsSinceEpoch}-${node.id}', - ), - ) - .toList(growable: false), - ); - await saveAutomation(copiedAutomation); - } - - Future deleteAutomation(String automationId) async { - final next = automations - .where((item) => item.id != automationId) - .toList(growable: false); - await saveSettings(_settings.copyWith(automations: next)); - } - - Future setAutomationEnabled(String automationId, bool enabled) async { - final next = automations - .map((item) { - if (item.id != automationId) { - return item; - } - return item.copyWith(enabled: enabled); - }) - .toList(growable: false); - await saveSettings(_settings.copyWith(automations: next)); - } - - Future startCommandExecution({ - required String commandText, - required String cwd, - required SandboxMode sandboxMode, - required bool allowNetwork, - required CommandSessionMode mode, - required int timeoutMs, - required bool disableTimeout, - required int outputBytesCap, - required bool disableOutputCap, - int rows = 20, - int cols = 80, - }) async { - await _startCommandExecutionInternal( - commandText: commandText, - cwd: cwd, - sandboxMode: sandboxMode, - allowNetwork: allowNetwork, - mode: mode, - timeoutMs: timeoutMs, - disableTimeout: disableTimeout, - outputBytesCap: outputBytesCap, - disableOutputCap: disableOutputCap, - rows: rows, - cols: cols, - rememberRecent: true, - awaitCompletion: false, - ); - } - - Future _startCommandExecutionInternal({ - required String commandText, - required String cwd, - required SandboxMode sandboxMode, - required bool allowNetwork, - required CommandSessionMode mode, - required int timeoutMs, - required bool disableTimeout, - required int outputBytesCap, - required bool disableOutputCap, - required bool rememberRecent, - required bool awaitCompletion, - int rows = 20, - int cols = 80, - }) async { - final trimmed = commandText.trim(); - if (trimmed.isEmpty) { - return null; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return null; - } - } - - final processId = 'cmd-${DateTime.now().microsecondsSinceEpoch}'; - final normalizedCwd = cwd.trim().isEmpty ? preferredCommandCwd : cwd.trim(); - final usesTty = _shouldUseTtyForCommand(trimmed, mode); - final session = CommandSession( - id: processId, - processId: processId, - commandDisplay: trimmed, - cwd: normalizedCwd, - mode: mode, - usesTty: usesTty, - startedAt: DateTime.now(), - ); - - if (rememberRecent) { - await _rememberRecentCommand( - RecentCommand( - commandText: trimmed, - cwd: normalizedCwd, - mode: mode, - sandboxMode: sandboxMode, - allowNetwork: allowNetwork, - disableTimeout: disableTimeout, - timeoutMs: timeoutMs, - disableOutputCap: disableOutputCap, - outputBytesCap: outputBytesCap, - ), - ); - } - - commandSessions.insert(0, session); - _commandSessionsById[session.id] = session; - _commandSessionsByProcessId[session.processId] = session; - activeCommandSessionId = session.id; - notifyListeners(); - - final id = _requestId++; - final completer = Completer?>(); - _pendingRequests[id] = completer; - _pendingCommandRequestsById[id] = session; - - final params = { - 'command': ['/bin/bash', '-lc', trimmed], - if (session.cwd.isNotEmpty) 'cwd': session.cwd, - 'processId': processId, - 'streamStdoutStderr': true, - 'sandboxPolicy': _buildCommandSandboxPolicy( - sandboxMode, - allowNetwork, - session.cwd, - ), - if (disableOutputCap) 'disableOutputCap': true, - if (!disableOutputCap && outputBytesCap > 0) - 'outputBytesCap': outputBytesCap, - if (disableTimeout) 'disableTimeout': true, - if (!disableTimeout && timeoutMs > 0) 'timeoutMs': timeoutMs, - if (mode == CommandSessionMode.interactive) ...{ - 'streamStdin': true, - if (usesTty) 'tty': true, - if (usesTty) 'size': {'rows': rows, 'cols': cols}, - }, - }; - - _send({ - 'id': id, - 'method': 'command/exec', - 'params': params, - }); - - if (mode == CommandSessionMode.interactive && usesTty) { - unawaited(_primeInteractiveSessionSize(session, rows: rows, cols: cols)); - } - - final completion = completer.future - .then((Map? result) { - _pendingCommandRequestsById.remove(id); - _completeCommandSession(session, result); - }) - .catchError((Object error) { - _pendingCommandRequestsById.remove(id); - session.status = 'failed'; - session.stderr = [ - session.stderr.trimRight(), - error.toString(), - ].where((item) => item.isNotEmpty).join('\n'); - notifyListeners(); - }); - if (awaitCompletion) { - await completion; - } else { - unawaited(completion); - } - return session; - } - - Future writeToCommandSession( - String sessionId, - String input, { - bool closeStdin = false, - }) async { - final session = _commandSessionsById[sessionId]; - if (session == null || !session.isRunning) { - return; - } - - final payload = input.isEmpty ? null : base64Encode(utf8.encode(input)); - final payloadField = payload == null - ? null - : {'deltaBase64': payload}; - await _request('command/exec/write', { - 'processId': session.processId, - ...?payloadField, - if (closeStdin) 'closeStdin': true, - }); - if (closeStdin) { - session.stdinClosed = true; - notifyListeners(); - } - } - - Future closeCommandSessionStdin(String sessionId) async { - await writeToCommandSession(sessionId, '', closeStdin: true); - } - - Future terminateCommandSession(String sessionId) async { - final session = _commandSessionsById[sessionId]; - if (session == null || !session.isRunning) { - return; - } - - await _request('command/exec/terminate', { - 'processId': session.processId, - }); - } - - Future resizeCommandSession( - String sessionId, { - required int rows, - required int cols, - }) async { - final session = _commandSessionsById[sessionId]; - if (session == null || - !session.isInteractive || - !session.usesTty || - !session.isRunning) { - return; - } - - await _request('command/exec/resize', { - 'processId': session.processId, - 'size': {'rows': rows, 'cols': cols}, - }); - } - - Future _primeInteractiveSessionSize( - CommandSession session, { - required int rows, - required int cols, - }) async { - const delays = [ - Duration.zero, - Duration(milliseconds: 250), - Duration(milliseconds: 1000), - ]; - - for (final delay in delays) { - if (!session.isInteractive || !session.usesTty || !session.isRunning) { - return; - } - if (delay > Duration.zero) { - await Future.delayed(delay); - } - try { - await _request('command/exec/resize', { - 'processId': session.processId, - 'size': {'rows': rows, 'cols': cols}, - }); - } catch (_) { - // Ignore resize failures; later attempts or layout-driven resize may still succeed. - } - } - } - - bool _shouldUseTtyForCommand(String commandText, CommandSessionMode mode) { - if (mode != CommandSessionMode.interactive) { - return false; - } - final trimmed = commandText.trim(); - if (trimmed.isEmpty) { - return true; - } - final prefersPlainStreaming = RegExp( - r'(^|\s)flutter(\s|$)', - ).hasMatch(trimmed); - return !prefersPlainStreaming; - } - - void selectCommandSession(String sessionId) { - if (_commandSessionsById.containsKey(sessionId)) { - activeCommandSessionId = sessionId; - notifyListeners(); - } - } - - Future clearFinishedCommandSessions() async { - commandSessions.removeWhere((session) => !session.isRunning); - _commandSessionsById.removeWhere( - (_, CommandSession session) => !session.isRunning, - ); - _commandSessionsByProcessId.removeWhere( - (_, CommandSession session) => !session.isRunning, - ); - if (activeCommandSessionId != null && - !_commandSessionsById.containsKey(activeCommandSessionId)) { - activeCommandSessionId = commandSessions.isEmpty - ? null - : commandSessions.first.id; - } - notifyListeners(); - } - - Future clearAllCommandSessions() async { - commandSessions.clear(); - _commandSessionsById.clear(); - _commandSessionsByProcessId.clear(); - activeCommandSessionId = null; - notifyListeners(); - } - - Future removeRecentCommand(RecentCommand target) async { - recentCommands.removeWhere( - (command) => _sameRecentCommand(command, target), - ); - await _settingsStore.saveRecentCommands(recentCommands); - notifyListeners(); - } - - Future loadThreadHistory({bool reset = false}) async { - if (isLoadingHistory) { - return; - } - final now = DateTime.now(); - final isFreshCache = - reset && - _threadHistoryLoadedAt != null && - now.difference(_threadHistoryLoadedAt!) < const Duration(seconds: 20) && - threadHistory.isNotEmpty; - if (isFreshCache) { - return; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - - if (reset) { - threadHistory.clear(); - _threadHistoryCursor = null; - threadHistoryError = null; - notifyListeners(); - } - - isLoadingHistory = true; - threadHistoryError = null; - notifyListeners(); - - try { - final response = await _request('thread/list', { - 'limit': 25, - 'sortKey': 'updated_at', - if (!reset && _threadHistoryCursor != null) - 'cursor': _threadHistoryCursor, - }); - final data = response?['data']; - final nextCursor = response?['nextCursor']; - final nextItems = []; - if (data is List) { - for (final item in data) { - final parsed = _parseThreadSummary(item); - if (parsed != null && parsed.id.isNotEmpty) { - nextItems.add(parsed); - } - } - } - - if (reset) { - threadHistory - ..clear() - ..addAll(nextItems); - } else { - final existingIds = threadHistory.map((item) => item.id).toSet(); - for (final item in nextItems) { - if (!existingIds.contains(item.id)) { - threadHistory.add(item); - } - } - } - _sortThreadHistory(); - - _threadHistoryCursor = nextCursor?.toString(); - _threadHistoryLoadedAt = DateTime.now(); - } catch (error) { - threadHistoryError = error.toString(); - } finally { - isLoadingHistory = false; - notifyListeners(); - } - } - - Future openFileBrowser({String? path}) async { - await loadDirectory(path ?? preferredFileBrowserRoot); - } - - Future loadModelOptions({bool force = false}) async { - if (isLoadingModels) { - return; - } - if (!force && - modelOptions.isNotEmpty && - _modelOptionsLoadedAt != null && - DateTime.now().difference(_modelOptionsLoadedAt!) < - const Duration(minutes: 5)) { - return; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - - isLoadingModels = true; - modelListError = null; - notifyListeners(); - - try { - final response = await _request('model/list', { - 'limit': 100, - }); - final data = response?['data']; - final nextOptions = []; - if (data is List) { - for (final item in data) { - if (item is! Map) { - continue; - } - nextOptions.add( - ModelOption( - id: item['id']?.toString() ?? '', - model: item['model']?.toString() ?? '', - displayName: item['displayName']?.toString() ?? '', - description: item['description']?.toString() ?? '', - isDefault: item['isDefault'] == true, - hidden: item['hidden'] == true, - ), - ); - } - } - nextOptions.sort((a, b) { - if (a.isDefault != b.isDefault) { - return a.isDefault ? -1 : 1; - } - return a.displayName.toLowerCase().compareTo( - b.displayName.toLowerCase(), - ); - }); - modelOptions - ..clear() - ..addAll(nextOptions.where((option) => !option.hidden)); - _modelOptionsLoadedAt = DateTime.now(); - } catch (error) { - modelListError = error.toString(); - } finally { - isLoadingModels = false; - notifyListeners(); - } - } - - Future loadDirectory(String path) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - fileBrowserError = 'File browser requires an absolute path.'; - notifyListeners(); - return; - } - - final cached = _directoryCache[normalizedPath]; - if (cached != null && - DateTime.now().difference(cached.loadedAt) < - const Duration(seconds: 20)) { - isLoadingFiles = false; - fileBrowserError = null; - fileBrowserPath = normalizedPath; - selectedFilePath = null; - selectedFileBytes = null; - selectedFileContent = null; - selectedFileIsHumanReadable = false; - selectedFileHighlightedLine = null; - fileBrowserEntries - ..clear() - ..addAll(cached.entries); - notifyListeners(); - return; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - - isLoadingFiles = true; - fileBrowserError = null; - fileBrowserPath = normalizedPath; - selectedFilePath = null; - selectedFileContent = null; - selectedFileBytes = null; - selectedFileIsHumanReadable = false; - selectedFileHighlightedLine = null; - notifyListeners(); - - try { - final nextEntries = await _readDirectoryEntries(normalizedPath); - nextEntries.sort((a, b) { - if (a.isDirectory != b.isDirectory) { - return a.isDirectory ? -1 : 1; - } - return a.fileName.toLowerCase().compareTo(b.fileName.toLowerCase()); - }); - fileBrowserEntries - ..clear() - ..addAll(nextEntries); - _directoryCache[normalizedPath] = _DirectoryCacheEntry( - entries: List.from(nextEntries), - loadedAt: DateTime.now(), - ); - } catch (error) { - fileBrowserError = error.toString(); - fileBrowserEntries.clear(); - } finally { - isLoadingFiles = false; - notifyListeners(); - } - } - - Future> _readDirectoryEntries(String path) async { - try { - final response = await _request('fs/readDirectory', { - 'path': path, - }, _directoryReadTimeout); - return _parseDirectoryEntries(response?['entries']); - } catch (_) { - return _readDirectoryEntriesViaCommand(path); - } - } - - List _parseDirectoryEntries(dynamic entriesRaw) { - final nextEntries = []; - if (entriesRaw is! List) { - return nextEntries; - } - for (final item in entriesRaw) { - if (item is! Map) { - continue; - } - nextEntries.add( - FileSystemEntry( - fileName: item['fileName']?.toString() ?? '', - isDirectory: item['isDirectory'] == true, - isFile: item['isFile'] == true, - ), - ); - } - return nextEntries; - } - - Future> _readDirectoryEntriesViaCommand( - String path, - ) async { - const script = ''' -import json -import os -import sys - -entries = [] -with os.scandir(sys.argv[1]) as it: - for entry in it: - try: - is_dir = entry.is_dir(follow_symlinks=False) - except OSError: - is_dir = False - try: - is_file = entry.is_file(follow_symlinks=False) - except OSError: - is_file = False - entries.append({ - "fileName": entry.name, - "isDirectory": is_dir, - "isFile": is_file, - }) - -print(json.dumps({"entries": entries})) -'''; - final response = await _request('command/exec', { - 'command': ['/usr/bin/env', 'python3', '-c', script, path], - 'sandboxPolicy': const {'type': 'readOnly'}, - }, _directoryReadTimeout); - final stdout = response?['stdout']?.toString() ?? ''; - if (stdout.trim().isEmpty) { - return []; - } - final decoded = jsonDecode(stdout) as Map; - return _parseDirectoryEntries(decoded['entries']); - } - - Future openFile(String path, {int? highlightedLine}) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - return; - } - - final cached = _filePreviewCache[normalizedPath]; - if (cached != null && - DateTime.now().difference(cached.loadedAt) < - const Duration(minutes: 2)) { - selectedFilePath = normalizedPath; - selectedFileBytes = cached.bytes; - selectedFileContent = cached.content; - selectedFileIsHumanReadable = cached.isHumanReadable; - selectedFileHighlightedLine = highlightedLine; - fileBrowserError = null; - notifyListeners(); - return; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - - isLoadingFilePreview = true; - filePreviewSaveError = null; - fileBrowserError = null; - selectedFilePath = normalizedPath; - selectedFileContent = null; - selectedFileBytes = null; - selectedFileIsHumanReadable = false; - selectedFileHighlightedLine = highlightedLine; - notifyListeners(); - - try { - final bytes = await readFileBytes(normalizedPath); - selectedFileBytes = bytes; - selectedFileIsHumanReadable = _isLikelyHumanReadableFile( - normalizedPath, - bytes, - ); - if (selectedFileIsHumanReadable) { - selectedFileContent = utf8.decode(bytes, allowMalformed: true); - } else { - selectedFileContent = null; - } - _filePreviewCache[normalizedPath] = _FilePreviewCacheEntry( - bytes: bytes, - content: selectedFileContent, - isHumanReadable: selectedFileIsHumanReadable, - loadedAt: DateTime.now(), - ); - } catch (error) { - fileBrowserError = error.toString(); - selectedFileBytes = null; - selectedFileContent = null; - selectedFileIsHumanReadable = false; - selectedFileHighlightedLine = null; - } finally { - isLoadingFilePreview = false; - notifyListeners(); - } - } - - Future saveOpenedFileContent(String content) async { - final selectedPath = selectedFilePath?.trim() ?? ''; - if (selectedPath.isEmpty) { - throw StateError('No file is open.'); - } - if (!selectedFileIsHumanReadable) { - throw StateError('This file cannot be edited as text.'); - } - if (!isConnected) { - await connect(); - if (!isConnected) { - throw StateError('Not connected.'); - } - } - - isSavingFilePreview = true; - filePreviewSaveError = null; - notifyListeners(); - try { - final bytes = Uint8List.fromList(utf8.encode(content)); - await _request('fs/writeFile', { - 'path': selectedPath, - 'dataBase64': base64Encode(bytes), - }, const Duration(minutes: 2)); - selectedFileContent = content; - selectedFileBytes = bytes; - _filePreviewCache[selectedPath] = _FilePreviewCacheEntry( - bytes: bytes, - content: content, - isHumanReadable: true, - loadedAt: DateTime.now(), - ); - } catch (error) { - filePreviewSaveError = error.toString(); - rethrow; - } finally { - isSavingFilePreview = false; - notifyListeners(); - } - } - - Future readFileBytes(String path) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - throw StateError('File browser requires an absolute path.'); - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - throw StateError('Not connected.'); - } - } - - final response = await _request('fs/readFile', { - 'path': normalizedPath, - }, const Duration(minutes: 2)); - final dataBase64 = response?['dataBase64']?.toString() ?? ''; - return Uint8List.fromList(base64Decode(dataBase64)); - } - - Future _downloadViaDirectHttpServer( - String path, { - required File targetFile, - ValueChanged? onProgress, - String? processId, - }) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - throw StateError('File browser requires an absolute path.'); - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - throw StateError('Not connected.'); - } - } - - onProgress?.call( - const FileDownloadStatus( - progress: 0, - receivedBytes: 0, - totalBytes: null, - eta: null, - ), - ); - final expectedBytes = await _readFileSizeViaCommand(normalizedPath); - final resolvedProcessId = - processId ?? 'download-${DateTime.now().microsecondsSinceEpoch}'; - final pending = _PendingDownload( - expectedBytes: expectedBytes, - onProgress: onProgress, - ); - _pendingDownloadsByProcessId[resolvedProcessId] = pending; - final pendingServer = _PendingTransferServer(); - _pendingTransferServersByProcessId[resolvedProcessId] = pendingServer; - final token = _randomTransferToken(); - - try { - final responseFuture = _request('command/exec', { - 'command': [ - '/usr/bin/env', - 'python3', - '-u', - '-c', - _directDownloadServerScript, - normalizedPath, - token, - ], - 'processId': resolvedProcessId, - 'streamStdoutStderr': true, - 'disableTimeout': true, - 'disableOutputCap': true, - 'sandboxPolicy': _buildCommandSandboxPolicy( - _settings.sandboxMode, - true, - preferredCommandCwd, - ), - }, const Duration(minutes: 30)); - - final endpoint = await pendingServer.waitForReady(); - final downloadUri = _buildDirectDownloadUri( - port: endpoint.port, - token: endpoint.token, - ); - await _downloadHttpFile( - uri: downloadUri, - targetFile: targetFile, - pending: pending, - ); - - final response = await responseFuture; - final exitCode = response?['exitCode'] as int?; - if (pending.isCancelled) { - throw const _DownloadCancelled(); - } - if (exitCode != null && exitCode != 0) { - if (pending.isCancelled) { - throw const _DownloadCancelled(); - } - final detail = pendingServer.stderr.trim(); - throw StateError( - detail.isEmpty - ? 'Download command failed with exit code $exitCode.' - : detail, - ); - } - pending.markProcessExited(); - await pending.waitForCompletion(); - onProgress?.call( - FileDownloadStatus( - progress: 1, - receivedBytes: pending.writtenBytes, - totalBytes: pending.expectedBytes ?? pending.writtenBytes, - eta: Duration.zero, - ), - ); - } finally { - _pendingDownloadsByProcessId.remove(resolvedProcessId); - _pendingTransferServersByProcessId.remove(resolvedProcessId); - } - } - - Future _downloadViaRelayHttp( - String path, { - required File targetFile, - required String processId, - ValueChanged? onProgress, - }) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - throw StateError('File browser requires an absolute path.'); - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - throw StateError('Not connected.'); - } - } - - final response = await _request('bridge/download/start', { - 'path': normalizedPath, - }, const Duration(minutes: 2)); - final url = response?['url']?.toString().trim() ?? ''; - if (url.isEmpty) { - throw StateError('Relay bridge did not provide a download URL.'); - } - final expectedBytes = response?['sizeBytes'] as int?; - onProgress?.call( - FileDownloadStatus( - progress: 0, - receivedBytes: 0, - totalBytes: expectedBytes, - eta: null, - ), - ); - final pending = _PendingDownload( - expectedBytes: expectedBytes, - onProgress: onProgress, - ); - _pendingDownloadsByProcessId[processId] = pending; - try { - await _downloadHttpFile( - uri: Uri.parse(url), - targetFile: targetFile, - pending: pending, - ); - onProgress?.call( - FileDownloadStatus( - progress: 1, - receivedBytes: pending.writtenBytes, - totalBytes: pending.expectedBytes ?? pending.writtenBytes, - eta: Duration.zero, - ), - ); - } finally { - _pendingDownloadsByProcessId.remove(processId); - } - } - - Future saveFileToDevice( - String path, { - String? preferredDirectory, - bool promptIfNeeded = true, - }) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - throw StateError('File browser requires an absolute path.'); - } - if (isFileDownloading(normalizedPath)) { - return null; - } - - final processId = 'download-${DateTime.now().microsecondsSinceEpoch}'; - _fileDownloadProcessIdByPath[normalizedPath] = processId; - _upsertDownloadRecord( - normalizedPath, - state: DownloadState.running, - status: const FileDownloadStatus( - progress: 0.04, - receivedBytes: 0, - totalBytes: null, - eta: null, - ), - ); - _setFileDownloadStatus( - normalizedPath, - const FileDownloadStatus( - progress: 0.04, - receivedBytes: 0, - totalBytes: null, - eta: null, - ), - ); - - File? targetFile; - try { - final fileName = normalizedPath - .split('/') - .where((part) => part.isNotEmpty) - .last; - final threadId = activeThreadId?.trim() ?? ''; - String? targetDirectory = preferredDirectory?.trim(); - if (targetDirectory == null || targetDirectory.isEmpty) { - targetDirectory = _preferredDownloadDirectoryForThread(threadId); - } - if (targetDirectory == null || targetDirectory.trim().isEmpty) { - if (!promptIfNeeded) { - throw StateError( - 'No download directory is configured for this thread.', - ); - } - targetDirectory = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Choose download location', - ); - if (targetDirectory != null && targetDirectory.trim().isNotEmpty) { - await _rememberDownloadDirectoryForThread(threadId, targetDirectory); - } - } - if (targetDirectory == null || targetDirectory.trim().isEmpty) { - return null; - } - final directory = Directory(targetDirectory); - await directory.create(recursive: true); - targetFile = await _nextAvailableFile(directory.path, fileName); - if (_settings.connectionMode == ConnectionMode.relay) { - await _downloadViaRelayHttp( - normalizedPath, - processId: processId, - targetFile: targetFile, - onProgress: (FileDownloadStatus status) { - _setFileDownloadStatus(normalizedPath, status); - }, - ); - } else { - await _downloadViaDirectHttpServer( - normalizedPath, - processId: processId, - targetFile: targetFile, - onProgress: (FileDownloadStatus status) { - _setFileDownloadStatus(normalizedPath, status); - }, - ); - } - final currentStatus = fileDownloadStatus(normalizedPath); - _setFileDownloadStatus( - normalizedPath, - FileDownloadStatus( - progress: 1, - receivedBytes: - currentStatus?.totalBytes ?? currentStatus?.receivedBytes ?? 0, - totalBytes: currentStatus?.totalBytes ?? currentStatus?.receivedBytes, - eta: Duration.zero, - ), - ); - _upsertDownloadRecord( - normalizedPath, - state: DownloadState.completed, - targetPath: targetFile.path, - status: fileDownloadStatus(normalizedPath), - ); - return targetFile.path; - } on _DownloadCancelled { - if (targetFile != null && await targetFile.exists()) { - await targetFile.delete(); - } - _upsertDownloadRecord( - normalizedPath, - state: DownloadState.cancelled, - targetPath: targetFile?.path, - status: fileDownloadStatus(normalizedPath), - ); - notifyListeners(); - return null; - } catch (error) { - if (targetFile != null && await targetFile.exists()) { - await targetFile.delete(); - } - _upsertDownloadRecord( - normalizedPath, - state: DownloadState.failed, - targetPath: targetFile?.path, - status: fileDownloadStatus(normalizedPath), - error: error.toString(), - ); - notifyListeners(); - rethrow; - } finally { - _fileDownloadProcessIdByPath.remove(normalizedPath); - await Future.delayed(const Duration(milliseconds: 220)); - _clearFileDownloadProgress(normalizedPath); - } - } - - Future cancelFileDownload(String path) async { - final normalizedPath = _normalizeAbsolutePath(path); - final processId = _fileDownloadProcessIdByPath[normalizedPath]; - if (processId == null) { - return; - } - final pending = _pendingDownloadsByProcessId[processId]; - pending?.cancel(); - try { - await _request('command/exec/terminate', { - 'processId': processId, - }); - } catch (_) { - // Ignore termination failures; local cancellation state is still enough. - } - } - - void clearFinishedDownloads() { - downloadRecords.removeWhere((item) => item.state != DownloadState.running); - notifyListeners(); - } - - Future _syncAutomationWatches() async { - final inFlight = _automationWatchSyncCompleter; - if (inFlight != null) { - _automationWatchSyncQueued = true; - return inFlight.future; - } - final completer = Completer(); - _automationWatchSyncCompleter = completer; - try { - do { - _automationWatchSyncQueued = false; - await _performAutomationWatchSync(); - } while (_automationWatchSyncQueued); - completer.complete(); - } catch (error, stackTrace) { - completer.completeError(error, stackTrace); - rethrow; - } finally { - _automationWatchSyncCompleter = null; - } - } - - Future _performAutomationWatchSync() async { - if (!isConnected) { - _activeAutomationWatches.clear(); - _registeredAutomationWatches.clear(); - return; - } - - final desired = {}; - for (final automation in automations) { - if (!automation.enabled) { - continue; - } - if (!isAutomationVisibleInCurrentThread(automation)) { - continue; - } - final trigger = automation.triggerNode; - if (trigger == null) { - continue; - } - if (trigger.kind == AutomationNodeKind.turnCompleted) { - continue; - } - final normalizedPath = _normalizeAbsolutePath(trigger.path); - if (normalizedPath.isEmpty) { - continue; - } - desired[automation.id] = trigger.copyWith(path: normalizedPath); - } - - final staleIds = _activeAutomationWatches.keys - .where((automationId) { - final active = _activeAutomationWatches[automationId]; - final desiredNode = desired[automationId]; - return active == null || - desiredNode == null || - active.path != desiredNode.path || - active.kind != desiredNode.kind; - }) - .toList(growable: false); - for (final automationId in staleIds) { - final active = _activeAutomationWatches.remove(automationId); - if (active == null) { - continue; - } - _automationDebounceTimers.remove(automationId)?.cancel(); - _debouncedAutomationChangedPaths.remove(automationId); - } - - final desiredPaths = desired.values.map((item) => item.path).toSet(); - final stalePaths = _registeredAutomationWatches.keys - .where((path) => !desiredPaths.contains(path)) - .toList(growable: false); - for (final path in stalePaths) { - final registered = _registeredAutomationWatches.remove(path); - if (registered == null) { - continue; - } - try { - await _request('fs/unwatch', { - 'watchId': registered.watchId, - }); - } catch (_) { - // Ignore best-effort cleanup failures during resync. - } - } - - for (final entry in desired.entries) { - final active = _activeAutomationWatches[entry.key]; - if (active != null && - active.path == entry.value.path && - active.kind == entry.value.kind) { - continue; - } - try { - final registered = await _ensureRegisteredAutomationWatch( - entry.value.path, - ); - if (registered == null) { - continue; - } - _activeAutomationWatches[entry.key] = _ActiveAutomationWatch( - automationId: entry.key, - watchId: registered.watchId, - path: registered.path, - kind: entry.value.kind, - ); - } catch (error) { - _addSystemEntry( - 'Automation watch failed for ${_automationName(entry.key)}: $error', - ); - } - } - notifyListeners(); - } - - Future<_RegisteredAutomationWatch?> _ensureRegisteredAutomationWatch( - String path, - ) async { - final existing = _registeredAutomationWatches[path]; - if (existing != null) { - return existing; - } - final response = await _request('fs/watch', { - 'path': path, - }); - final watchId = response?['watchId']?.toString() ?? ''; - if (watchId.isEmpty) { - return null; - } - final registered = _RegisteredAutomationWatch( - watchId: watchId, - path: response?['path']?.toString() ?? path, - ); - _registeredAutomationWatches[path] = registered; - return registered; - } - - Future _handleAutomationFsChanged( - String watchId, - List changedPaths, - ) async { - final activeWatches = _activeAutomationWatches.values - .where((watch) => watch.watchId == watchId) - .toList(growable: false); - if (activeWatches.isEmpty) { - return; - } - for (final activeWatch in activeWatches) { - AutomationDefinition? automation; - for (final item in automations) { - if (item.id == activeWatch.automationId) { - automation = item; - break; - } - } - if (automation == null || !automation.enabled) { - continue; - } - final relevantPaths = _matchingAutomationChangedPaths( - activeWatch, - changedPaths, - ); - if (relevantPaths.isEmpty) { - continue; - } - final automationId = automation.id; - final pendingPaths = { - ...?_debouncedAutomationChangedPaths[automationId], - ...relevantPaths, - }.toList(growable: false); - _debouncedAutomationChangedPaths[automationId] = pendingPaths; - _automationDebounceTimers.remove(automationId)?.cancel(); - _automationDebounceTimers[automationId] = Timer( - _automationFsQuietPeriod, - () { - _automationDebounceTimers.remove(automationId); - final stabilizedPaths = - _debouncedAutomationChangedPaths.remove(automationId) ?? - const []; - if (stabilizedPaths.isEmpty) { - return; - } - unawaited( - _triggerAutomationAfterQuietPeriod( - automation!, - activeWatch, - stabilizedPaths, - ), - ); - }, - ); - } - } - - Future _triggerAutomationAfterQuietPeriod( - AutomationDefinition automation, - _ActiveAutomationWatch activeWatch, - List relevantPaths, - ) async { - if (_runningAutomationIds.contains(automation.id)) { - _queuedAutomationChangedPaths[automation.id] = relevantPaths; - notifyListeners(); - return; - } - _runningAutomationIds.add(automation.id); - notifyListeners(); - unawaited( - _runAutomation(automation, activeWatch, relevantPaths).whenComplete( - () async { - _runningAutomationIds.remove(automation.id); - notifyListeners(); - final queued = _queuedAutomationChangedPaths.remove(automation.id); - if (queued != null && queued.isNotEmpty) { - await _handleAutomationFsChanged(activeWatch.watchId, queued); - } - }, - ), - ); - } - - List _matchingAutomationChangedPaths( - _ActiveAutomationWatch activeWatch, - List changedPaths, - ) { - final normalized = changedPaths - .map(_normalizeAbsolutePath) - .where((item) => item.isNotEmpty) - .toList(growable: false); - if (activeWatch.kind == AutomationNodeKind.watchFileChanged) { - return normalized.where((item) => item == activeWatch.path).toList(); - } - return normalized.where((item) { - return item == activeWatch.path || - item.startsWith('${activeWatch.path}/'); - }).toList(); - } - - Future _runAutomation( - AutomationDefinition automation, - _ActiveAutomationWatch activeWatch, - List changedPaths, - ) async { - final context = _AutomationExecutionContext( - changedPaths: changedPaths, - watchedPath: activeWatch.path, - triggerKind: activeWatch.kind, - ); - _addSystemEntry('Automation "${automation.name}" triggered.'); - try { - for (final node in automation.actionNodes) { - switch (node.kind) { - case AutomationNodeKind.turnCompleted: - break; - case AutomationNodeKind.didPathChangeSinceLastRun: - final comparisonPath = _resolveAutomationComparisonPath( - node, - context, - activeWatch, - ); - if (comparisonPath.isEmpty) { - throw StateError( - 'No file or folder path was configured to compare.', - ); - } - final currentSnapshot = await _captureAutomationSnapshot( - comparisonPath, - ); - final previousSnapshot = _automationSnapshotFor( - automation.id, - comparisonPath, - ); - final changed = - previousSnapshot == null || previousSnapshot != currentSnapshot; - await _storeAutomationSnapshot( - automation.id, - comparisonPath, - currentSnapshot, - ); - context.recordNodeOutput(node.id, { - 'changed': changed ? 'true' : 'false', - 'path': comparisonPath, - 'snapshot': currentSnapshot, - }); - case AutomationNodeKind.ifElse: - final outcome = _evaluateAutomationBranch(node, context); - context.recordNodeOutput(node.id, { - 'condition': _resolveAutomationConditionValue(node, context), - 'outcome': outcome.name, - }); - if (outcome == AutomationBranchOutcome.quitFlow) { - _addSystemEntry( - 'Automation "${automation.name}" stopped by ${node.kind.title}.', - ); - return; - } - case AutomationNodeKind.quit: - context.recordNodeOutput(node.id, { - 'outcome': AutomationBranchOutcome.quitFlow.name, - }); - _addSystemEntry( - 'Automation "${automation.name}" stopped by ${node.kind.title}.', - ); - return; - case AutomationNodeKind.downloadChangedFile: - final sourcePath = _resolveAutomationDownloadSourcePath( - node, - context, - activeWatch, - ); - if (sourcePath == null) { - throw StateError('No changed file was available to download.'); - } - final target = await saveFileToDevice( - sourcePath, - preferredDirectory: - _resolveAutomationTemplate( - node.directory, - context, - ).trim().isEmpty - ? null - : _resolveAutomationTemplate(node.directory, context).trim(), - promptIfNeeded: false, - ); - if (target == null || target.trim().isEmpty) { - throw StateError('Download was cancelled.'); - } - context.lastDownloadedPath = target; - context.recordNodeOutput(node.id, { - 'sourcePath': sourcePath, - 'downloadedPath': target, - }); - case AutomationNodeKind.installDownloadedApk: - final installPath = _resolveAutomationInstallPath(node, context); - if (installPath.isEmpty) { - throw StateError('No downloaded file was available to install.'); - } - if (!installPath.toLowerCase().endsWith('.apk')) { - throw StateError('The downloaded file is not an APK.'); - } - final opened = await _openPath(installPath); - if (!opened) { - throw StateError('Unable to open the downloaded APK.'); - } - context.recordNodeOutput(node.id, { - 'installedPath': installPath, - }); - case AutomationNodeKind.sendMessageToCurrentThread: - final messageText = _resolveAutomationTemplate( - node.commandText, - context, - ).trim(); - if (messageText.isEmpty) { - throw StateError('Automation message is empty.'); - } - await _sendAutomationMessage(messageText); - context.recordNodeOutput(node.id, { - 'messageText': messageText, - }); - case AutomationNodeKind.runCommand: - final commandText = _resolveAutomationTemplate( - node.commandText, - context, - ).trim(); - if (commandText.isEmpty) { - throw StateError('Automation command is empty.'); - } - final resolvedCwd = _resolveAutomationTemplate( - node.cwd, - context, - ).trim(); - final cwd = resolvedCwd.isNotEmpty - ? resolvedCwd - : _defaultAutomationCommandCwd(context); - final session = await _runAutomationCommand(commandText, cwd: cwd); - context.recordNodeOutput(node.id, { - 'commandText': commandText, - 'cwd': cwd, - 'stdout': session?.stdout ?? '', - 'stderr': session?.stderr ?? '', - 'processId': session?.processId ?? '', - }); - case AutomationNodeKind.watchFileChanged: - case AutomationNodeKind.watchDirectoryChanged: - // Trigger nodes are handled by fs/watch registration. - break; - } - } - _addSystemEntry('Automation "${automation.name}" completed.'); - } catch (error) { - _addSystemEntry('Automation "${automation.name}" failed: $error'); - } - } - - String? _resolveAutomationChangedFile( - _AutomationExecutionContext context, - _ActiveAutomationWatch activeWatch, - ) { - if (activeWatch.kind == AutomationNodeKind.watchFileChanged) { - return context.changedPaths.isEmpty - ? activeWatch.path - : context.changedPaths.first; - } - for (final path in context.changedPaths) { - if (!_looksLikeDirectoryPath(path)) { - return path; - } - } - return null; - } - - String? _resolveAutomationDownloadSourcePath( - AutomationNode node, - _AutomationExecutionContext context, - _ActiveAutomationWatch activeWatch, - ) { - final configuredPath = _resolveAutomationTemplate( - node.path, - context, - ).trim(); - if (configuredPath.isNotEmpty) { - return configuredPath; - } - return _resolveAutomationChangedFile(context, activeWatch); - } - - String _resolveAutomationComparisonPath( - AutomationNode node, - _AutomationExecutionContext context, - _ActiveAutomationWatch activeWatch, - ) { - final configuredPath = _resolveAutomationTemplate( - node.path, - context, - ).trim(); - if (configuredPath.isNotEmpty) { - return configuredPath; - } - if (context.triggerKind == AutomationNodeKind.watchDirectoryChanged || - context.triggerKind == AutomationNodeKind.watchFileChanged) { - return activeWatch.path; - } - return context.watchedPath; - } - - String _resolveAutomationInstallPath( - AutomationNode node, - _AutomationExecutionContext context, - ) { - final configuredPath = _resolveAutomationTemplate( - node.path, - context, - ).trim(); - if (configuredPath.isNotEmpty) { - return configuredPath; - } - final previousDownloadedPath = - context.valueForToken('previous.downloadedPath')?.trim() ?? ''; - if (previousDownloadedPath.isNotEmpty) { - return previousDownloadedPath; - } - return context.lastDownloadedPath?.trim() ?? ''; - } - - String _resolveAutomationConditionValue( - AutomationNode node, - _AutomationExecutionContext context, - ) { - final template = node.conditionToken.trim().isEmpty - ? '{{previous.changed}}' - : node.conditionToken.trim(); - return _resolveAutomationTemplate(template, context).trim(); - } - - AutomationBranchOutcome _evaluateAutomationBranch( - AutomationNode node, - _AutomationExecutionContext context, - ) { - final value = _resolveAutomationConditionValue(node, context).toLowerCase(); - final isTruthy = - value == 'true' || - value == '1' || - value == 'yes' || - value == 'y' || - value == 'continue'; - return isTruthy ? node.whenTrue : node.whenFalse; - } - - bool _looksLikeDirectoryPath(String path) { - final parts = path.split('/').where((part) => part.isNotEmpty).toList(); - final name = parts.isEmpty ? '' : parts.last; - return name.isEmpty || !name.contains('.'); - } - - String _defaultAutomationCommandCwd(_AutomationExecutionContext context) { - if (context.triggerKind == AutomationNodeKind.turnCompleted) { - return preferredCommandCwd; - } - if (context.triggerKind == AutomationNodeKind.watchDirectoryChanged) { - return context.watchedPath; - } - final segments = context.watchedPath - .split('/') - .where((part) => part.isNotEmpty) - .toList(); - if (segments.isEmpty) { - return preferredCommandCwd; - } - final parent = '/${segments.take(segments.length - 1).join('/')}'; - return parent == '/' ? parent : _normalizeAbsolutePath(parent); - } - - Future _runAutomationCommand( - String commandText, { - required String cwd, - }) async { - return _startCommandExecutionInternal( - commandText: commandText, - cwd: cwd, - sandboxMode: _settings.sandboxMode, - allowNetwork: _settings.allowNetwork, - mode: CommandSessionMode.buffered, - timeoutMs: 30 * 60 * 1000, - disableTimeout: true, - outputBytesCap: 32768, - disableOutputCap: true, - rememberRecent: false, - awaitCompletion: true, - ); - } - - Future _sendAutomationMessage(String messageText) async { - if (!isConnected) { - await connect(); - if (!isConnected) { - throw StateError('Unable to connect to the app-server.'); - } - } - if (activeThreadId == null || activeThreadId!.trim().isEmpty) { - throw StateError('No active thread is available.'); - } - if (hasActiveTurn) { - _enqueuePendingPrompt(messageText, PendingPromptMode.queued); - return; - } - await _startTurn(messageText, const []); - } - - String _resolveAutomationTemplate( - String value, - _AutomationExecutionContext context, - ) { - if (value.isEmpty) { - return value; - } - return value.replaceAllMapped( - RegExp(r'\{\{\s*([^}]+?)\s*\}\}'), - (match) => context.valueForToken(match.group(1)?.trim() ?? '') ?? '', - ); - } - - String _automationName(String automationId) { - for (final automation in automations) { - if (automation.id == automationId) { - return automation.name; - } - } - return automationId; - } - - String? _automationSnapshotFor(String automationId, String path) { - return _settings.automationSnapshots[automationId]?[path]; - } - - Future _storeAutomationSnapshot( - String automationId, - String path, - String snapshot, - ) async { - final normalizedAutomationId = automationId.trim(); - final normalizedPath = path.trim(); - if (normalizedAutomationId.isEmpty || - normalizedPath.isEmpty || - snapshot.trim().isEmpty) { - return; - } - final nextSnapshots = >{}; - for (final entry in _settings.automationSnapshots.entries) { - nextSnapshots[entry.key] = Map.from(entry.value); - } - final automationSnapshots = - nextSnapshots[normalizedAutomationId] ?? {}; - automationSnapshots[normalizedPath] = snapshot; - nextSnapshots[normalizedAutomationId] = automationSnapshots; - _settings = _settings.copyWith(automationSnapshots: nextSnapshots); - await _settingsStore.save(_settings); - } - - Future _captureAutomationSnapshot(String path) async { - final normalizedPath = _normalizeAbsolutePath(path); - if (normalizedPath.isEmpty) { - throw StateError('Automation comparison path must be absolute.'); - } - final metadata = await _request('fs/getMetadata', { - 'path': normalizedPath, - }); - if (metadata == null) { - throw StateError('Unable to read metadata for $normalizedPath.'); - } - final isDirectory = metadata['isDirectory'] == true; - final isFile = metadata['isFile'] == true; - final modifiedAtMs = metadata['modifiedAtMs']?.toString() ?? '0'; - final createdAtMs = metadata['createdAtMs']?.toString() ?? '0'; - if (isFile) { - return 'file|$normalizedPath|$createdAtMs|$modifiedAtMs'; - } - if (!isDirectory) { - return 'missing|$normalizedPath'; - } - final entries = await _readDirectoryEntries(normalizedPath); - final signatures = []; - for (final entry in entries) { - final fileName = entry.fileName; - if (fileName.trim().isEmpty) { - continue; - } - final childPath = normalizedPath == '/' - ? '/$fileName' - : '$normalizedPath/$fileName'; - final childMetadata = await _request('fs/getMetadata', { - 'path': childPath, - }); - final childModifiedAtMs = - childMetadata?['modifiedAtMs']?.toString() ?? '0'; - final childType = childMetadata?['isDirectory'] == true ? 'dir' : 'file'; - signatures.add('$fileName|$childType|$childModifiedAtMs'); - } - signatures.sort(); - return 'dir|$normalizedPath|$modifiedAtMs|${signatures.join(';')}'; - } - - Future _handleAutomationTurnCompleted(String turnId) async { - for (final automation in automations) { - if (!automation.enabled) { - continue; - } - if (!isAutomationVisibleInCurrentThread(automation)) { - continue; - } - final trigger = automation.triggerNode; - if (trigger?.kind != AutomationNodeKind.turnCompleted) { - continue; - } - if (_runningAutomationIds.contains(automation.id)) { - continue; - } - _runningAutomationIds.add(automation.id); - notifyListeners(); - final syntheticTrigger = _ActiveAutomationWatch( - automationId: automation.id, - watchId: 'turn-completed:$turnId', - path: preferredCommandCwd, - kind: AutomationNodeKind.turnCompleted, - ); - unawaited( - _runAutomation(automation, syntheticTrigger, [ - turnId, - ]).whenComplete(() { - _runningAutomationIds.remove(automation.id); - notifyListeners(); - }), - ); - } - } - - Uri _buildDirectDownloadUri({required int port, required String token}) { - final serverUri = Uri.parse(_settings.serverUrl); - final scheme = serverUri.scheme == 'wss' ? 'https' : 'http'; - final host = serverUri.host; - if (host.isEmpty) { - throw StateError('Cannot determine a download host from the server URL.'); - } - return Uri(scheme: scheme, host: host, port: port, path: '/$token'); - } - - Future _downloadHttpFile({ - required Uri uri, - required File targetFile, - required _PendingDownload pending, - }) async { - final client = _httpClientFactory(); - IOSink? sink; - try { - final request = await client.getUrl(uri); - final response = await request.close(); - if (response.statusCode != HttpStatus.ok) { - throw StateError('Download server returned ${response.statusCode}.'); - } - sink = targetFile.openWrite(mode: FileMode.writeOnly); - await for (final chunk in response) { - if (pending.isCancelled) { - throw const _DownloadCancelled(); - } - sink.add(chunk); - pending.addBytes(chunk.length); - } - await sink.flush(); - await sink.close(); - } finally { - client.close(force: true); - if (sink != null) { - try { - await sink.close(); - } catch (_) {} - } - } - } - - String _randomTransferToken() { - const chars = - 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - final random = Random.secure(); - final buffer = StringBuffer(); - for (var index = 0; index < 24; index += 1) { - buffer.write(chars[random.nextInt(chars.length)]); - } - return buffer.toString(); - } - - static const String _directDownloadServerScript = r''' -import http.server -import json -import os -import socketserver -import sys -import time - -file_path = sys.argv[1] -token = sys.argv[2] - -class OneShotHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path != f"/{token}": - self.send_response(404) - self.end_headers() - return - stat = os.stat(file_path) - self.send_response(200) - self.send_header("Content-Type", "application/octet-stream") - self.send_header("Content-Length", str(stat.st_size)) - self.send_header( - "Content-Disposition", - f'attachment; filename="{os.path.basename(file_path)}"', - ) - self.end_headers() - with open(file_path, "rb") as handle: - while True: - chunk = handle.read(1024 * 1024) - if not chunk: - break - self.wfile.write(chunk) - self.wfile.flush() - self.server.served = True - - def log_message(self, format, *args): - return - -class OneShotServer(socketserver.ThreadingMixIn, http.server.HTTPServer): - daemon_threads = True - allow_reuse_address = True - -server = OneShotServer(("0.0.0.0", 0), OneShotHandler) -server.served = False -server.timeout = 1 -print(json.dumps({ - "event": "ready", - "port": server.server_address[1], - "token": token, -}), flush=True) - -deadline = time.time() + 300 -while not server.served and time.time() < deadline: - server.handle_request() -'''; - - Future _readFileSizeViaCommand(String path) async { - try { - final response = await _request('command/exec', { - 'command': ['/bin/bash', '-lc', r'wc -c < "$1"', 'bash', path], - 'sandboxPolicy': _buildCommandSandboxPolicy( - _settings.sandboxMode, - false, - preferredCommandCwd, - ), - }, const Duration(seconds: 30)); - final stdout = response?['stdout']?.toString().trim() ?? ''; - return int.tryParse(stdout); - } catch (_) { - return null; - } - } - - String _joinFilePath(String directory, String fileName) { - final normalizedDirectory = directory.endsWith(Platform.pathSeparator) - ? directory.substring(0, directory.length - 1) - : directory; - return '$normalizedDirectory${Platform.pathSeparator}$fileName'; - } - - Future _nextAvailableFile(String directory, String fileName) async { - final dotIndex = fileName.lastIndexOf('.'); - final hasExtension = dotIndex > 0; - final baseName = hasExtension ? fileName.substring(0, dotIndex) : fileName; - final extension = hasExtension ? fileName.substring(dotIndex) : ''; - var candidate = File(_joinFilePath(directory, fileName)); - var suffix = 1; - while (await candidate.exists()) { - candidate = File( - _joinFilePath(directory, '$baseName ($suffix)$extension'), - ); - suffix += 1; - } - return candidate; - } - - String? resolveFileReferencePath(String rawPath) { - final trimmed = rawPath.trim(); - if (trimmed.isEmpty) { - return null; - } - final absolute = _normalizeAbsolutePath(trimmed); - if (absolute.isNotEmpty) { - return absolute; - } - final base = preferredCommandCwd.trim(); - if (base.isEmpty) { - return null; - } - return _normalizeAbsolutePath(_joinFilePath(base, trimmed)); - } - - void _setFileDownloadStatus(String path, FileDownloadStatus value) { - _fileDownloadStatusByPath[path] = value; - _upsertDownloadRecord(path, status: value, state: DownloadState.running); - notifyListeners(); - } - - void _clearFileDownloadProgress(String path) { - if (_fileDownloadStatusByPath.remove(path) != null) { - notifyListeners(); - } - } - - void _upsertDownloadRecord( - String path, { - required DownloadState state, - FileDownloadStatus? status, - String? targetPath, - String? error, - }) { - final normalizedPath = _normalizeAbsolutePath(path); - final parts = normalizedPath - .split('/') - .where((part) => part.isNotEmpty) - .toList(); - final fileName = parts.isEmpty ? normalizedPath : parts.last; - final index = downloadRecords.indexWhere( - (item) => item.sourcePath == normalizedPath, - ); - final previous = index >= 0 ? downloadRecords[index] : null; - final next = DownloadRecord( - sourcePath: normalizedPath, - fileName: fileName, - targetPath: targetPath ?? previous?.targetPath, - state: state, - status: status ?? previous?.status, - error: error ?? previous?.error, - startedAt: previous?.startedAt ?? DateTime.now(), - finishedAt: state == DownloadState.running ? null : DateTime.now(), - ); - if (index >= 0) { - downloadRecords[index] = next; - } else { - downloadRecords.insert(0, next); - } - } - - Future navigateToParentDirectory() async { - final current = fileBrowserPath.trim(); - if (current.isEmpty || current == '/') { - return; - } - final slashIndex = current.lastIndexOf('/'); - final parent = slashIndex <= 0 ? '/' : current.substring(0, slashIndex); - await loadDirectory(parent); - } - - String joinFileBrowserPath(String childName) { - final base = fileBrowserPath.trim(); - if (base.isEmpty || base == '/') { - return '/$childName'; - } - return '$base/$childName'; - } - - Future resumeThreadFromHistory(String threadId) async { - if (threadId.isEmpty) { - return; - } - if (activeThreadId == threadId) { - return; - } - if (isOpeningThread) { - return; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - - openingThreadId = threadId; - notifyListeners(); - - try { - final readResponse = await _request('thread/read', { - 'threadId': threadId, - 'includeTurns': true, - }); - final thread = readResponse?['thread']; - if (thread is Map) { - _hydrateEntriesFromThread(thread); - activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; - activeThreadName = thread['name']?.toString() ?? activeThreadName; - } - - final resumeResponse = await _request('thread/resume', { - 'threadId': threadId, - }); - final resumed = resumeResponse?['thread']; - final resumedTurn = resumeResponse?['turn']; - if (resumed is Map) { - activeThreadId = resumed['id']?.toString() ?? threadId; - _subscribedThreadId = activeThreadId; - activeThreadCwd = resumed['cwd']?.toString() ?? activeThreadCwd; - activeThreadName = resumed['name']?.toString() ?? activeThreadName; - } else { - activeThreadId = threadId; - _subscribedThreadId = threadId; - } - if (resumedTurn is Map) { - _hydrateResumeTurn(resumedTurn); - } else { - activeTurnId = _activeTurnIdsByThread[threadId]; - statusMessage = activeTurnId == null ? 'Ready' : 'Turn running'; - } - - await saveSettings( - _settings.copyWith(resumeThreadId: activeThreadId ?? threadId), - ); - _resyncAutomationWatchesForCurrentThread(); - _addSystemEntry( - 'Resumed thread ${activeThreadName?.trim().isNotEmpty == true ? activeThreadName : activeThreadId ?? threadId}.', - ); - } catch (error) { - _addSystemEntry('Thread resume failed: $error'); - } finally { - openingThreadId = null; - notifyListeners(); - } - } - - Future renameThread(String threadId, String name) async { - final trimmed = name.trim(); - if (threadId.trim().isEmpty || trimmed.isEmpty) { - return; - } - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - await _request('thread/name/set', { - 'threadId': threadId, - 'name': trimmed, - }); - _updateThreadSummaryName(threadId, trimmed); - if (activeThreadId == threadId) { - activeThreadName = trimmed; - } - notifyListeners(); - } - - Future sendPrompt( - String prompt, { - List attachments = const [], - }) async { - final trimmed = prompt.trim(); - final normalizedAttachments = List.from(attachments); - if (trimmed.isEmpty && normalizedAttachments.isEmpty) { - return; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return; - } - } - - await _ensureThread(); - if (activeThreadId == null) { - _addSystemEntry('Unable to start a thread.'); - return; - } - - if (hasActiveTurn) { - _enqueuePendingPrompt( - trimmed, - PendingPromptMode.queued, - attachments: normalizedAttachments, - ); - return; - } - - await _startTurn(trimmed, normalizedAttachments); - } - - Future steerPrompt( - String prompt, { - List attachments = const [], - }) async { - final trimmed = prompt.trim(); - final normalizedAttachments = List.from(attachments); - if (trimmed.isEmpty && normalizedAttachments.isEmpty) { - return false; - } - - if (!isConnected) { - await connect(); - if (!isConnected) { - return false; - } - } - - await _ensureThread(); - if (activeThreadId == null || !hasActiveTurn || isSteering) { - return false; - } - _enqueuePendingPrompt( - trimmed, - PendingPromptMode.steer, - attachments: normalizedAttachments, - ); - return true; - } - - Future interruptTurn() async { - if (activeThreadId == null || activeTurnId == null) { - return; - } - - try { - await _request('turn/interrupt', { - 'threadId': activeThreadId, - 'turnId': activeTurnId, - }); - _addSystemEntry('Interrupt requested for $activeTurnId.'); - } catch (error) { - _addSystemEntry('Interrupt failed: $error'); - } - notifyListeners(); - } - - Future _startTurn( - String prompt, - List attachments, - ) async { - final input = _buildUserInput(prompt, attachments); - final optimisticEntryKey = _addOptimisticUserEntry(prompt, attachments); - final containsImageAttachment = attachments.any((item) => item.isImage); - try { - final response = await _request( - 'turn/start', - { - 'threadId': activeThreadId, - 'input': input, - if (activeThreadCwd.trim().isNotEmpty) 'cwd': activeThreadCwd.trim(), - if (_settings.model.trim().isNotEmpty) - 'model': _settings.model.trim(), - if (_settings.reasoningEffort.trim().isNotEmpty) - 'effort': _settings.reasoningEffort.trim(), - 'approvalPolicy': normalizeApprovalPolicy(_settings.approvalPolicy), - 'sandboxPolicy': _buildSandboxPolicy(), - 'personality': 'pragmatic', - }, - containsImageAttachment - ? const Duration(minutes: 5) - : const Duration(seconds: 20), - ); - - final turn = response?['turn']; - if (turn is Map) { - activeTurnId = turn['id'] as String?; - final threadId = activeThreadId?.trim() ?? ''; - if (threadId.isNotEmpty && activeTurnId != null) { - _activeTurnIdsByThread[threadId] = activeTurnId!; - } - statusMessage = 'Turn running'; - notifyListeners(); - } - } catch (error) { - _discardOptimisticUserEntry(optimisticEntryKey); - _addSystemEntry('Prompt failed: $error'); - notifyListeners(); - } - } - - Future _steerPrompt( - String prompt, - List attachments, - ) async { - if (activeThreadId == null || activeTurnId == null) { - return false; - } - final containsImageAttachment = attachments.any((item) => item.isImage); - - try { - isSteering = true; - notifyListeners(); - final response = await _request( - 'turn/steer', - { - 'threadId': activeThreadId, - 'expectedTurnId': activeTurnId, - 'input': _buildUserInput(prompt, attachments), - }, - containsImageAttachment - ? const Duration(minutes: 5) - : const Duration(seconds: 20), - ); - final turnId = response?['turnId']?.toString(); - if (turnId != null && turnId.isNotEmpty) { - activeTurnId = turnId; - final threadId = activeThreadId?.trim() ?? ''; - if (threadId.isNotEmpty) { - _activeTurnIdsByThread[threadId] = turnId; - } - } - _addSystemEntry('Sent as steer input.'); - return true; - } catch (_) { - return false; - } finally { - isSteering = false; - notifyListeners(); - } - } - - void _enqueuePendingPrompt( - String prompt, - PendingPromptMode mode, { - List attachments = const [], - }) { - pendingPrompts.add( - PendingPrompt( - id: 'pending-${DateTime.now().microsecondsSinceEpoch}', - text: prompt, - mode: mode, - attachments: List.from(attachments), - ), - ); - notifyListeners(); - unawaited(_processPendingPrompts()); - } - - void cancelPendingPrompt(String id) { - pendingPrompts.removeWhere((item) => item.id == id); - notifyListeners(); - } - - PendingPrompt? takePendingPromptForEditing(String id) { - final index = pendingPrompts.indexWhere((item) => item.id == id); - if (index < 0) { - return null; - } - final value = pendingPrompts.removeAt(index); - notifyListeners(); - return value; - } - - bool promotePendingPromptToSteer(String id) { - final index = pendingPrompts.indexWhere((item) => item.id == id); - if (index < 0) { - return false; - } - final current = pendingPrompts[index]; - if (current.mode == PendingPromptMode.steer) { - return false; - } - pendingPrompts[index] = current.copyWith(mode: PendingPromptMode.steer); - notifyListeners(); - unawaited(_processPendingPrompts()); - return true; - } - - Future _processPendingPrompts() async { - if (pendingPrompts.isEmpty) { - return; - } - final nextPrompt = pendingPrompts.first; - if (nextPrompt.mode == PendingPromptMode.steer) { - if (!hasActiveTurn || isSteering) { - return; - } - final accepted = await _steerPrompt( - nextPrompt.text, - nextPrompt.attachments, - ); - if (accepted) { - pendingPrompts.removeWhere((item) => item.id == nextPrompt.id); - notifyListeners(); - } - return; - } - if (hasActiveTurn) { - return; - } - await _startTurn(nextPrompt.text, nextPrompt.attachments); - pendingPrompts.removeWhere((item) => item.id == nextPrompt.id); - notifyListeners(); - } - - List> _buildUserInput( - String prompt, - List attachments, - ) { - final input = >[]; - final fileAttachments = attachments - .where((item) => item.isTextFile) - .toList(growable: false); - final imageAttachments = attachments - .where((item) => item.isImage) - .toList(growable: false); - final text = _composeTextInput(prompt, fileAttachments); - if (text.isNotEmpty) { - input.add({'type': 'text', 'text': text}); - } - for (final attachment in imageAttachments) { - final dataUrl = attachment.dataUrl; - if (dataUrl == null || dataUrl.isEmpty) { - continue; - } - input.add({'type': 'image', 'url': dataUrl}); - } - return input; - } - - String _addOptimisticUserEntry( - String prompt, - List attachments, - ) { - final text = _composeTextInput( - prompt, - attachments.where((item) => item.isTextFile).toList(), - ); - final imageCount = attachments.where((item) => item.isImage).length; - final body = [ - text.trim(), - if (imageCount > 0) - imageCount == 1 ? '[1 image]' : '[$imageCount images]', - ].where((item) => item.isNotEmpty).join('\n'); - final key = 'local-user-${DateTime.now().microsecondsSinceEpoch}'; - entries.add( - ActivityEntry( - key: key, - kind: EntryKind.user, - title: 'You', - body: body, - isLocalPending: true, - ), - ); - _pendingOptimisticUserEntryKeys.add(key); - notifyListeners(); - return key; - } - - void _discardOptimisticUserEntry(String key) { - _pendingOptimisticUserEntryKeys.remove(key); - entries.removeWhere((entry) => entry.key == key); - } - - ActivityEntry _resolveOptimisticUserEntry(String actualItemId) { - while (_pendingOptimisticUserEntryKeys.isNotEmpty) { - final pendingKey = _pendingOptimisticUserEntryKeys.removeAt(0); - final index = entries.indexWhere((entry) => entry.key == pendingKey); - if (index < 0) { - continue; - } - final pending = entries[index]; - final resolved = ActivityEntry( - key: actualItemId, - kind: EntryKind.user, - title: pending.title, - body: pending.body, - secondary: pending.secondary, - status: pending.status, - timestamp: pending.timestamp, - ); - entries[index] = resolved; - return resolved; - } - return _createEntry(actualItemId, 'userMessage', const { - 'type': 'userMessage', - }); - } - - String _composeTextInput( - String prompt, - List fileAttachments, - ) { - final sections = []; - final trimmed = prompt.trim(); - if (trimmed.isNotEmpty) { - sections.add(trimmed); - } - for (final attachment in fileAttachments) { - final content = attachment.textContent?.trim() ?? ''; - if (content.isEmpty) { - continue; - } - sections.add( - 'Attached file: ${attachment.fileName}\n```text\n$content\n```', - ); - } - return sections.join('\n\n'); - } - - Future resolveApproval( - PendingApproval approval, - String decision, - ) async { - _send({ - 'id': approval.requestId, - 'result': {'decision': decision}, - }); - approvals.removeWhere((item) => item.requestId == approval.requestId); - notifyListeners(); - } - - Future _refreshUsageMetadata({bool notify = true}) async { - if (!isConnected) { - if (notify) { - notifyListeners(); - } - return; - } - - try { - final rateResponse = await _request( - 'account/rateLimits/read', - null, - const Duration(seconds: 20), - ); - final snapshot = _selectRateLimitSnapshot(rateResponse); - rateLimitSummary = _formatRateLimitSummary(snapshot); - rateLimitResetDetails = _formatRateLimitResetDetails(snapshot); - } catch (_) { - rateLimitSummary = null; - rateLimitResetDetails = const []; - } - - try { - final configResponse = await _request( - 'config/read', - {}, - const Duration(seconds: 20), - ); - final config = configResponse?['config']; - if (config is Map) { - final contextState = _contextStateFromConfig(config); - contextWindowSummary = contextState.$1; - contextUsagePercent = contextState.$2; - } else { - contextWindowSummary = null; - contextUsagePercent = null; - } - } catch (_) { - contextWindowSummary = null; - contextUsagePercent = null; - } - - if (notify) { - notifyListeners(); - } - } - - Future _ensureThread({bool forceNew = false}) async { - if (!forceNew && activeThreadId != null) { - return; - } - - if (!forceNew && _settings.resumeThreadId.trim().isNotEmpty) { - try { - final response = await _request('thread/resume', { - 'threadId': _settings.resumeThreadId.trim(), - }); - final thread = response?['thread']; - if (thread is Map) { - final threadId = thread['id'] as String?; - if (threadId != null) { - activeThreadId = threadId; - _subscribedThreadId = threadId; - activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; - activeThreadName = thread['name']?.toString() ?? activeThreadName; - _addSystemEntry( - 'Resumed thread ${activeThreadName?.trim().isNotEmpty == true ? activeThreadName : threadId}.', - ); - notifyListeners(); - _resyncAutomationWatchesForCurrentThread(); - return; - } - } - } catch (_) { - _addSystemEntry( - 'Stored thread ${_settings.resumeThreadId} could not be resumed. Starting a new thread.', - ); - } - } - - final initialCwd = _pendingNewThreadCwd?.trim() ?? ''; - final response = await _request('thread/start', { - if (initialCwd.isNotEmpty) 'cwd': initialCwd, - }); - final thread = response?['thread']; - if (thread is! Map) { - return; - } - final threadId = thread['id'] as String?; - if (threadId == null) { - return; - } - activeThreadId = threadId; - _subscribedThreadId = threadId; - activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; - activeThreadName = thread['name']?.toString() ?? activeThreadName; - _pendingNewThreadCwd = null; - await saveSettings(_settings.copyWith(resumeThreadId: threadId)); - _resyncAutomationWatchesForCurrentThread(); - _addSystemEntry( - 'Opened thread ${activeThreadName?.trim().isNotEmpty == true ? activeThreadName : threadId}.', - ); - } - - Map _buildSandboxPolicy() { - switch (_settings.sandboxMode) { - case SandboxMode.workspaceWrite: - final writableRoots = activeThreadCwd.trim().isEmpty - ? const [] - : [activeThreadCwd.trim()]; - return { - 'type': 'workspaceWrite', - if (writableRoots.isNotEmpty) 'writableRoots': writableRoots, - 'networkAccess': _settings.allowNetwork, - }; - case SandboxMode.readOnly: - return {'type': 'readOnly'}; - case SandboxMode.dangerFullAccess: - return {'type': 'dangerFullAccess'}; - } - } - - Map _buildCommandSandboxPolicy( - SandboxMode sandboxMode, - bool allowNetwork, - String cwd, - ) { - switch (sandboxMode) { - case SandboxMode.workspaceWrite: - final writableRoots = cwd.trim().isEmpty - ? const [] - : [cwd]; - return { - 'type': 'workspaceWrite', - if (writableRoots.isNotEmpty) 'writableRoots': writableRoots, - 'networkAccess': allowNetwork, - }; - case SandboxMode.readOnly: - return {'type': 'readOnly'}; - case SandboxMode.dangerFullAccess: - return {'type': 'dangerFullAccess'}; - } - } - - Future?> _request( - String method, [ - Object? params = const {}, - Duration timeout = const Duration(seconds: 20), - ]) async { - final id = _requestId++; - final completer = Completer?>(); - _pendingRequests[id] = completer; - final paramsField = {'params': params}; - _send({'id': id, 'method': method, ...paramsField}); - return completer.future.timeout( - timeout, - onTimeout: () { - _pendingRequests.remove(id); - throw TimeoutException('Request timed out: $method', timeout); - }, - ); - } - - void _notify(String method, [Map? params]) { - final paramsField = params == null - ? null - : {'params': params}; - _send({'method': method, ...?paramsField}); - } - - void _send(Map payload) { - unawaited(_transport.send(jsonEncode(payload))); - } - - void _handleSocketMessage(String rawMessage) { - final dynamic decoded = jsonDecode(rawMessage); - if (decoded is! Map) { - return; - } - - final method = decoded['method'] as String?; - final id = decoded['id']; - - if (method == null && id is int) { - _handleResponse(id, decoded); - return; - } - - if (method != null && id is int) { - _handleServerRequest(id, method, decoded['params']); - return; - } - - if (method != null) { - _handleNotification(method, decoded['params']); - } - } - - void _handleResponse(int id, Map message) { - final completer = _pendingRequests.remove(id); - if (completer == null || completer.isCompleted) { - return; - } - - final error = message['error']; - if (error != null) { - completer.completeError(error.toString()); - return; - } - - final result = message['result']; - if (result is Map) { - completer.complete(result); - } else { - completer.complete(null); - } - } - - void _handleServerRequest(int id, String method, dynamic params) { - final typedParams = params is Map - ? params - : {}; - _pushEvent(method, typedParams); - - if (method == 'item/commandExecution/requestApproval' || - method == 'item/fileChange/requestApproval') { - final detail = switch (method) { - 'item/commandExecution/requestApproval' => - typedParams['command']?.toString() ?? - typedParams['reason']?.toString() ?? - '', - _ => - typedParams['reason']?.toString() ?? - typedParams['itemId']?.toString() ?? - '', - }; - final approval = PendingApproval( - requestId: id, - method: method, - itemId: typedParams['itemId']?.toString() ?? '$id', - title: method == 'item/commandExecution/requestApproval' - ? 'Command approval' - : 'File change approval', - detail: detail, - availableDecisions: - (typedParams['availableDecisions'] as List?) - ?.map((item) => item.toString()) - .toList() ?? - const ['accept', 'acceptForSession', 'decline', 'cancel'], - ); - approvals.removeWhere((item) => item.requestId == approval.requestId); - approvals.add(approval); - notifyListeners(); - return; - } - - _send({ - 'id': id, - 'error': { - 'code': -32601, - 'message': 'Unsupported request: $method', - }, - }); - } - - void _handleNotification(String method, dynamic params) { - final typedParams = params is Map - ? params - : {}; - _pushEvent(method, typedParams); - - switch (method) { - case 'android/transportStatus': - final transportStatus = typedParams['status']?.toString() ?? ''; - switch (transportStatus) { - case 'connected': - status = ConnectionStatus.ready; - statusMessage = 'Ready'; - case 'disconnected': - status = ConnectionStatus.disconnected; - statusMessage = 'Disconnected'; - case 'error': - status = ConnectionStatus.error; - statusMessage = 'Connection error'; - } - notifyListeners(); - case 'thread/started': - final thread = typedParams['thread']; - if (thread is Map) { - final threadId = thread['id']?.toString(); - if (threadId != null && activeThreadId == null) { - activeThreadId = threadId; - _subscribedThreadId = threadId; - activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; - activeThreadName = thread['name']?.toString() ?? activeThreadName; - unawaited( - saveSettings(_settings.copyWith(resumeThreadId: threadId)), - ); - _resyncAutomationWatchesForCurrentThread(); - } - } - case 'thread/status/changed': - final threadId = typedParams['threadId']?.toString(); - final status = _statusText(typedParams['status']); - if (threadId != null && status.isNotEmpty) { - final index = threadHistory.indexWhere((item) => item.id == threadId); - if (index >= 0) { - final current = threadHistory[index]; - threadHistory[index] = ThreadSummary( - id: current.id, - preview: current.preview, - cwd: current.cwd, - source: current.source, - modelProvider: current.modelProvider, - createdAt: current.createdAt, - updatedAt: current.updatedAt, - status: status, - name: current.name, - agentNickname: current.agentNickname, - agentRole: current.agentRole, - ); - notifyListeners(); - } - } - case 'turn/started': - final turn = typedParams['turn']; - if (turn is Map) { - final threadId = - typedParams['threadId']?.toString() ?? activeThreadId?.trim() ?? ''; - final turnId = turn['id']?.toString(); - if (threadId.isNotEmpty && turnId != null && turnId.isNotEmpty) { - _activeTurnIdsByThread[threadId] = turnId; - } - if (threadId == (activeThreadId?.trim() ?? '')) { - activeTurnId = turnId; - statusMessage = 'Turn running'; - notifyListeners(); - } - } - case 'turn/completed': - final turn = typedParams['turn']; - if (turn is Map) { - final threadId = - typedParams['threadId']?.toString() ?? activeThreadId?.trim() ?? ''; - if (threadId.isNotEmpty) { - _activeTurnIdsByThread.remove(threadId); - } - final turnStatus = turn['status']?.toString() ?? 'completed'; - if (threadId == (activeThreadId?.trim() ?? '')) { - activeTurnId = null; - isSteering = false; - statusMessage = 'Ready'; - if (turnStatus != 'completed') { - _addSystemEntry('Turn finished with status $turnStatus.'); - } - final error = turn['error']; - if (error is Map) { - _addSystemEntry(error['message']?.toString() ?? 'Turn failed.'); - } - notifyListeners(); - } - final turnId = - turn['id']?.toString() ?? typedParams['turnId']?.toString() ?? ''; - if (turnStatus == 'completed' && turnId.isNotEmpty) { - unawaited(_handleAutomationTurnCompleted(turnId)); - } - if (threadId == (activeThreadId?.trim() ?? '')) { - unawaited(_processPendingPrompts()); - } - } - case 'item/started': - final itemThreadId = typedParams['threadId']?.toString() ?? ''; - if (itemThreadId.isEmpty || - itemThreadId == (activeThreadId?.trim() ?? '')) { - _handleItem( - typedParams['item'], - isCompleted: false, - turnId: typedParams['turnId']?.toString(), - ); - } - case 'item/completed': - final itemThreadId = typedParams['threadId']?.toString() ?? ''; - if (itemThreadId.isEmpty || - itemThreadId == (activeThreadId?.trim() ?? '')) { - _handleItem( - typedParams['item'], - isCompleted: true, - turnId: typedParams['turnId']?.toString(), - ); - } - case 'item/agentMessage/delta': - final itemId = typedParams['itemId']?.toString(); - final delta = typedParams['delta']?.toString() ?? ''; - if (itemId != null) { - final entry = _entryByItemId[itemId]; - if (entry != null) { - entry.body += delta; - entry.isStreaming = true; - notifyListeners(); - } - } - case 'item/reasoning/summaryTextDelta': - final itemId = typedParams['itemId']?.toString(); - final delta = typedParams['delta']?.toString() ?? ''; - if (itemId != null) { - final entry = _entryByItemId[itemId]; - if (entry != null) { - entry.body += delta; - notifyListeners(); - } - } - case 'item/commandExecution/outputDelta': - final itemId = typedParams['itemId']?.toString(); - final delta = typedParams['delta']?.toString() ?? ''; - if (itemId != null) { - final entry = _entryByItemId[itemId]; - if (entry != null) { - entry.body += delta; - notifyListeners(); - } - } - case 'command/exec/outputDelta': - final processId = typedParams['processId']?.toString(); - final deltaBase64 = typedParams['deltaBase64']?.toString(); - if (processId != null && deltaBase64 != null) { - final rawBytes = base64Decode(deltaBase64); - final pendingServer = _pendingTransferServersByProcessId[processId]; - if (pendingServer != null) { - final stream = typedParams['stream']?.toString() ?? 'stdout'; - final decoded = utf8.decode(rawBytes, allowMalformed: true); - if (stream == 'stderr') { - pendingServer.stderr += decoded; - } else { - pendingServer.handleStdout(decoded); - } - } - final pendingDownload = _pendingDownloadsByProcessId[processId]; - if (pendingDownload != null) { - final stream = typedParams['stream']?.toString() ?? 'stdout'; - if (stream == 'stderr') { - pendingDownload.stderr += utf8.decode( - rawBytes, - allowMalformed: true, - ); - } - } - final session = _commandSessionsByProcessId[processId]; - if (session != null) { - final decoded = utf8.decode(rawBytes, allowMalformed: true); - final stream = typedParams['stream']?.toString() ?? 'stdout'; - if (stream == 'stderr') { - session.stderr = _appendCommandOutput(session.stderr, decoded); - } else { - session.stdout = _appendCommandOutput(session.stdout, decoded); - } - if (typedParams['capReached'] == true) { - session.outputCapReached = true; - } - notifyListeners(); - } - } - case 'serverRequest/resolved': - final requestId = typedParams['requestId']; - approvals.removeWhere((item) => item.requestId == requestId); - notifyListeners(); - case 'account/rateLimits/updated': - final snapshot = _selectRateLimitSnapshot(typedParams); - rateLimitSummary = _formatRateLimitSummary(snapshot); - rateLimitResetDetails = _formatRateLimitResetDetails(snapshot); - notifyListeners(); - case 'thread/tokenUsage/updated': - final threadId = typedParams['threadId']?.toString() ?? ''; - if (threadId == activeThreadId) { - final tokenUsage = typedParams['tokenUsage']; - if (tokenUsage is Map) { - final contextState = _contextStateFromTokenUsage(tokenUsage); - contextWindowSummary = contextState.$1; - contextUsagePercent = contextState.$2; - } - notifyListeners(); - } - case 'fs/changed': - final watchId = typedParams['watchId']?.toString() ?? ''; - final changedPaths = - (typedParams['changedPaths'] as List? ?? const []) - .map((item) => item.toString()) - .toList(growable: false); - if (watchId.isNotEmpty && changedPaths.isNotEmpty) { - unawaited(_handleAutomationFsChanged(watchId, changedPaths)); - } - case 'error': - final error = typedParams['error']; - if (error is Map) { - _addSystemEntry(error['message']?.toString() ?? 'Server error'); - notifyListeners(); - } - } - } - - void _handleItem(dynamic item, {required bool isCompleted, String? turnId}) { - if (item is! Map) { - return; - } - - final itemId = item['id']?.toString(); - final type = item['type']?.toString() ?? 'unknown'; - if (itemId == null) { - return; - } - - final entry = - _entryByItemId[itemId] ?? - (type == 'userMessage' - ? _resolveOptimisticUserEntry(itemId) - : _createEntry(itemId, type, item)); - _entryByItemId[itemId] = entry; - - switch (type) { - case 'userMessage': - entry.isLocalPending = false; - entry.body = _extractUserText(item['content']); - case 'agentMessage': - entry.body = item['text']?.toString() ?? entry.body; - entry.isStreaming = !isCompleted; - case 'reasoning': - entry.body = _extractReasoningText(item); - case 'commandExecution': - entry.title = item['command']?.toString() ?? entry.title; - entry.secondary = item['cwd']?.toString() ?? entry.secondary; - entry.body = item['aggregatedOutput']?.toString() ?? entry.body; - entry.status = item['status']?.toString() ?? entry.status; - case 'fileChange': - entry.body = _extractFileChanges(item['changes']); - entry.status = item['status']?.toString() ?? entry.status; - case 'mcpToolCall': - case 'collabAgentToolCall': - case 'dynamicToolCall': - case 'webSearch': - case 'plan': - entry.body = _summarizeMap(item); - entry.status = item['status']?.toString() ?? entry.status; - case 'enteredReviewMode': - case 'exitedReviewMode': - case 'contextCompaction': - entry.body = _summarizeMap(item); - default: - entry.body = _summarizeMap(item); - } - - if (isCompleted) { - entry.isStreaming = false; - final itemStatus = item['status']?.toString(); - if (itemStatus != null && itemStatus.isNotEmpty) { - entry.status = itemStatus; - } - if (type == 'agentMessage' && - item['phase']?.toString() == 'final_answer' && - turnId != null && - turnId.isNotEmpty) { - if (pendingPrompts.isNotEmpty) { - unawaited(_processPendingPrompts()); - } - } - } - - notifyListeners(); - } - - ActivityEntry _createEntry( - String itemId, - String type, - Map item, - ) { - final entry = ActivityEntry( - key: itemId, - kind: switch (type) { - 'userMessage' => EntryKind.user, - 'agentMessage' => EntryKind.agent, - 'reasoning' => EntryKind.reasoning, - 'commandExecution' => EntryKind.command, - 'fileChange' => EntryKind.fileChange, - 'mcpToolCall' || - 'collabAgentToolCall' || - 'dynamicToolCall' || - 'webSearch' || - 'plan' => EntryKind.tool, - _ => EntryKind.system, - }, - title: switch (type) { - 'userMessage' => 'You', - 'agentMessage' => 'Codex', - 'reasoning' => 'Reasoning', - 'commandExecution' => 'Command', - 'fileChange' => 'File change', - 'mcpToolCall' => 'MCP tool', - 'collabAgentToolCall' => 'Collaboration', - 'dynamicToolCall' => 'Dynamic tool', - 'webSearch' => 'Web search', - 'plan' => 'Plan', - 'enteredReviewMode' => 'Review started', - 'exitedReviewMode' => 'Review finished', - 'contextCompaction' => 'Compaction', - _ => type, - }, - body: '', - status: item['status']?.toString() ?? '', - ); - entries.add(entry); - return entry; - } - - String _extractUserText(dynamic content) { - if (content is! List) { - return ''; - } - - return content - .map((item) { - if (item is! Map) { - return ''; - } - if (item['type'] == 'text') { - return item['text']?.toString() ?? ''; - } - if (item['type'] == 'image') { - return '[image] ${item['url'] ?? ''}'; - } - if (item['type'] == 'localImage') { - return '[local image] ${item['path'] ?? ''}'; - } - return ''; - }) - .where((item) => item.isNotEmpty) - .join('\n'); - } - - String _extractReasoningText(Map item) { - final summary = item['summary']; - if (summary is List) { - final text = summary - .map((part) => part?.toString() ?? '') - .where((part) => part.isNotEmpty) - .join('\n'); - if (text.isNotEmpty) { - return text; - } - } - final content = item['content']; - if (content is List) { - return content - .map((part) => part?.toString() ?? '') - .where((part) => part.isNotEmpty) - .join('\n'); - } - return ''; - } - - String _extractFileChanges(dynamic changes) { - if (changes is! List) { - return ''; - } - - return changes - .map((change) { - if (change is! Map) { - return ''; - } - final path = change['path']?.toString() ?? ''; - final kind = change['kind']?.toString() ?? ''; - final diff = change['diff']?.toString() ?? ''; - final header = [ - path, - kind, - ].where((item) => item.isNotEmpty).join(' • '); - return [header, diff].where((item) => item.isNotEmpty).join('\n'); - }) - .where((item) => item.isNotEmpty) - .join('\n\n'); - } - - String _summarizeMap(Map item) { - final copy = Map.from(item)..remove('id'); - return const JsonEncoder.withIndent(' ').convert(copy); - } - - void _addSystemEntry(String message) { - entries.add( - ActivityEntry( - key: 'system-${DateTime.now().microsecondsSinceEpoch}', - kind: EntryKind.system, - title: 'System', - body: message, - ), - ); - notifyListeners(); - } - - void _pushEvent(String method, Map params) { - final summary = switch (method) { - 'item/agentMessage/delta' => params['delta']?.toString() ?? '', - 'item/commandExecution/outputDelta' => params['delta']?.toString() ?? '', - _ => _singleLineSummary(params), - }; - eventLog.insert(0, EventLogEntry(method, summary)); - if (eventLog.length > 60) { - eventLog.removeRange(60, eventLog.length); - } - } - - String _singleLineSummary(Map params) { - if (params.isEmpty) { - return ''; - } - final text = const JsonEncoder.withIndent(' ').convert(params); - return text.replaceAll('\n', ' ').trim(); - } - - CommandSession? get activeCommandSession { - if (activeCommandSessionId == null) { - return commandSessions.isEmpty ? null : commandSessions.first; - } - return _commandSessionsById[activeCommandSessionId!] ?? - (commandSessions.isEmpty ? null : commandSessions.first); - } - - void _completeCommandSession( - CommandSession session, - Map? result, - ) { - session.exitCode = result?['exitCode'] as int?; - final stdout = result?['stdout']?.toString() ?? ''; - final stderr = result?['stderr']?.toString() ?? ''; - if (stdout.isNotEmpty) { - session.stdout = _appendCommandOutput(session.stdout, stdout); - } - if (stderr.isNotEmpty) { - session.stderr = _appendCommandOutput(session.stderr, stderr); - } - session.status = session.exitCode == 0 ? 'completed' : 'failed'; - notifyListeners(); - } - - Future _rememberRecentCommand(RecentCommand next) async { - recentCommands.removeWhere( - (RecentCommand current) => _sameRecentCommand(current, next), - ); - recentCommands.insert(0, next); - if (recentCommands.length > 8) { - recentCommands.removeRange(8, recentCommands.length); - } - await _settingsStore.saveRecentCommands(recentCommands); - notifyListeners(); - } - - bool _sameRecentCommand(RecentCommand left, RecentCommand right) { - return left.commandText == right.commandText && - left.cwd == right.cwd && - left.mode == right.mode && - left.sandboxMode == right.sandboxMode && - left.allowNetwork == right.allowNetwork && - left.disableTimeout == right.disableTimeout && - left.timeoutMs == right.timeoutMs && - left.disableOutputCap == right.disableOutputCap && - left.outputBytesCap == right.outputBytesCap; - } - - Map? _selectRateLimitSnapshot( - Map? response, - ) { - if (response == null) { - return null; - } - final byLimitId = response['rateLimitsByLimitId']; - if (byLimitId is Map && byLimitId.isNotEmpty) { - final preferred = byLimitId['codex']; - if (preferred is Map) { - return preferred; - } - for (final value in byLimitId.values) { - if (value is Map) { - return value; - } - } - } - final snapshot = response['rateLimits']; - return snapshot is Map ? snapshot : null; - } - - String? _formatRateLimitSummary(Map? snapshot) { - if (snapshot == null) { - return null; - } - final segments = []; - final primary = snapshot['primary']; - if (primary is Map) { - final label = _formatRateLimitWindow(primary); - if (label != null) { - segments.add(label); - } - } - final secondary = snapshot['secondary']; - if (secondary is Map) { - final label = _formatRateLimitWindow(secondary); - if (label != null) { - segments.add(label); - } - } - final credits = snapshot['credits']; - if (segments.isEmpty && credits is Map) { - if (credits['unlimited'] == true) { - return 'unlimited'; - } - final balance = credits['balance']?.toString(); - if (balance != null && balance.isNotEmpty) { - return 'Credits $balance'; - } - } - if (segments.isEmpty) { - return null; - } - return segments.join(' • '); - } - - List _formatRateLimitResetDetails(Map? snapshot) { - if (snapshot == null) { - return const []; - } - final details = []; - final primary = snapshot['primary']; - if (primary is Map) { - final detail = _formatRateLimitResetDetail(primary); - if (detail != null) { - details.add(detail); - } - } - final secondary = snapshot['secondary']; - if (secondary is Map) { - final detail = _formatRateLimitResetDetail(secondary); - if (detail != null) { - details.add(detail); - } - } - return details; - } - - String? _formatRateLimitWindow(Map window) { - final usedPercent = _parsePositiveInt(window['usedPercent']); - if (usedPercent == null) { - return null; - } - final remaining = (100 - usedPercent).clamp(0, 100); - final duration = window['windowDurationMins']; - final durationLabel = _formatWindowDuration(duration); - return durationLabel == null - ? '$remaining% left' - : '$durationLabel $remaining% left'; - } - - String? _formatRateLimitResetDetail(Map window) { - final durationLabel = _formatWindowDuration(window['windowDurationMins']); - final resetAt = _parseRateLimitResetAt(window); - if (durationLabel == null || resetAt == null) { - return null; - } - return '$durationLabel resets ${_formatRateLimitResetAt(resetAt)}'; - } - - (String?, int?) _contextStateFromConfig(Map config) { - final contextWindow = _parsePositiveInt(config['model_context_window']); - final compactLimit = _parsePositiveInt( - config['model_auto_compact_token_limit'], - ); - if (contextWindow == null && compactLimit == null) { - return (null, null); - } - if (contextWindow != null) { - return ('${_formatTokenCount(contextWindow)} window', null); - } - if (compactLimit != null) { - return ('${_formatTokenCount(compactLimit)} compact', null); - } - return (null, null); - } - - (String?, int?) _contextStateFromTokenUsage(Map tokenUsage) { - final last = tokenUsage['last']; - final lastMap = last is Map ? last : null; - final lastTurnTokens = _parsePositiveInt(lastMap?['totalTokens']); - final contextWindow = _parsePositiveInt(tokenUsage['modelContextWindow']); - if (lastTurnTokens == null && contextWindow == null) { - return (null, null); - } - if (lastTurnTokens != null && contextWindow != null && contextWindow > 0) { - final usedPercent = ((lastTurnTokens / contextWindow) * 100) - .round() - .clamp(0, 100); - return ('$usedPercent% last/window', usedPercent); - } - if (lastTurnTokens != null) { - return ('${_formatTokenCount(lastTurnTokens)} last', null); - } - return ('${_formatTokenCount(contextWindow!)} window', null); - } - - int? _parsePositiveInt(dynamic value) { - if (value is int) { - return value > 0 ? value : null; - } - if (value is num) { - final asInt = value.round(); - return asInt > 0 ? asInt : null; - } - if (value is String) { - final parsed = int.tryParse(value.trim()); - if (parsed != null && parsed > 0) { - return parsed; - } - } - return null; - } - - DateTime? _parseRateLimitResetAt(Map window) { - const candidates = [ - 'resetsAt', - 'resetAt', - 'resetsAtIso', - 'resetAtIso', - 'resetsAtUnixMs', - 'resetAtUnixMs', - 'resetsAtMs', - 'resetAtMs', - 'resetsAtUnix', - 'resetAtUnix', - ]; - for (final key in candidates) { - final value = window[key]; - if (value == null) { - continue; - } - if (value is String) { - final parsed = DateTime.tryParse(value.trim()); - if (parsed != null) { - return parsed.toLocal(); - } - final asInt = int.tryParse(value.trim()); - if (asInt != null) { - return _dateTimeFromEpochGuess(asInt); - } - } - if (value is int) { - return _dateTimeFromEpochGuess(value); - } - if (value is num) { - return _dateTimeFromEpochGuess(value.round()); - } - } - return null; - } - - DateTime _dateTimeFromEpochGuess(int value) { - final isMilliseconds = value.abs() >= 100000000000; - return isMilliseconds - ? DateTime.fromMillisecondsSinceEpoch(value).toLocal() - : DateTime.fromMillisecondsSinceEpoch(value * 1000).toLocal(); - } - - String? _formatWindowDuration(dynamic minutesValue) { - if (minutesValue is! int || minutesValue <= 0) { - return null; - } - if (minutesValue % 1440 == 0) { - return '${minutesValue ~/ 1440}d'; - } - if (minutesValue % 60 == 0) { - return '${minutesValue ~/ 60}h'; - } - return '${minutesValue}m'; - } - - String _formatRateLimitResetAt(DateTime value) { - const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - final month = monthNames[value.month - 1]; - final day = value.day.toString().padLeft(2, '0'); - final hour = value.hour.toString().padLeft(2, '0'); - final minute = value.minute.toString().padLeft(2, '0'); - return '$month $day, $hour:$minute'; - } - - String _formatTokenCount(int value) { - if (value >= 1000000) { - final millions = value / 1000000; - final text = millions.toStringAsFixed( - millions.truncateToDouble() == millions ? 0 : 1, - ); - return '${text}M'; - } - if (value >= 1000) { - final thousands = value / 1000; - final text = thousands.toStringAsFixed( - thousands.truncateToDouble() == thousands ? 0 : 1, - ); - return '${text}k'; - } - return value.toString(); - } - - String _normalizeCommandOutput(String value) { - final csiPattern = RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'); - final oscPattern = RegExp(r'\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)'); - final withoutOsc = value.replaceAll(oscPattern, ''); - final withoutAnsi = withoutOsc.replaceAll(csiPattern, ''); - final terminalText = _applyTerminalControls(withoutAnsi); - return _collapseSingleCharacterLines(terminalText); - } - - String _appendCommandOutput(String existing, String nextChunk) { - return _normalizeCommandOutput(existing + nextChunk); - } - - String _collapseSingleCharacterLines(String value) { - final lines = value.split('\n'); - if (lines.length < 8) { - return value; - } - - final collapsed = []; - final singleCharRun = []; - - void flushRun() { - if (singleCharRun.isEmpty) { - return; - } - final nonEmpty = singleCharRun.where((line) => line.isNotEmpty).toList(); - final mostlySingleChar = - nonEmpty.length >= 6 && - nonEmpty.every((line) { - final trimmed = line.trim(); - return line.runes.length == 1 || trimmed.runes.length == 1; - }); - if (mostlySingleChar) { - final joined = singleCharRun.join(); - if (collapsed.isNotEmpty && joined.startsWith(RegExp(r'\s'))) { - collapsed[collapsed.length - 1] = '${collapsed.last}$joined'; - } else { - collapsed.add(joined); - } - } else { - collapsed.addAll(singleCharRun); - } - singleCharRun.clear(); - } - - for (final line in lines) { - final trimmed = line.trim(); - final isRepairableSingleChar = - line.isEmpty || line.runes.length == 1 || trimmed.runes.length == 1; - if (isRepairableSingleChar) { - singleCharRun.add(line); - } else { - flushRun(); - collapsed.add(line); - } - } - flushRun(); - - return collapsed.join('\n'); - } - - String _applyTerminalControls(String value) { - final lines = []; - var currentLine = []; - var cursor = 0; - - void writeChar(String char) { - if (cursor > currentLine.length) { - currentLine.addAll( - List.filled(cursor - currentLine.length, ' '), - ); - } - if (cursor == currentLine.length) { - currentLine.add(char); - } else { - currentLine[cursor] = char; - } - cursor += 1; - } - - void commitLine() { - lines.add(currentLine.join()); - currentLine = []; - cursor = 0; - } - - for (final rune in value.runes) { - if (rune == 10) { - commitLine(); - continue; - } - if (rune == 13) { - cursor = 0; - continue; - } - if (rune == 8) { - if (cursor > 0) { - cursor -= 1; - } - continue; - } - if (rune == 9) { - final spaces = 4 - (cursor % 4); - for (var index = 0; index < spaces; index += 1) { - writeChar(' '); - } - continue; - } - final isControl = rune < 32 || (rune >= 127 && rune <= 159); - if (!isControl) { - writeChar(String.fromCharCode(rune)); - } - } - - lines.add(currentLine.join()); - return lines.join('\n'); - } - - ThreadSummary? _parseThreadSummary(dynamic item) { - if (item is! Map) { - return null; - } - - return ThreadSummary( - id: item['id']?.toString() ?? '', - preview: item['preview']?.toString() ?? '', - cwd: item['cwd']?.toString() ?? '', - source: _sourceText(item['source']), - modelProvider: item['modelProvider']?.toString() ?? '', - createdAt: _parseUnixTimestamp(item['createdAt']), - updatedAt: _parseUnixTimestamp(item['updatedAt']), - status: _statusText(item['status']), - name: item['name']?.toString(), - agentNickname: item['agentNickname']?.toString(), - agentRole: item['agentRole']?.toString(), - ); - } - - DateTime? _parseUnixTimestamp(dynamic value) { - if (value is int) { - return DateTime.fromMillisecondsSinceEpoch( - value * 1000, - isUtc: true, - ).toLocal(); - } - return null; - } - - String _statusText(dynamic status) { - if (status is Map) { - return status['type']?.toString() ?? ''; - } - return status?.toString() ?? ''; - } - - String _sourceText(dynamic source) { - if (source is Map) { - return source['type']?.toString() ?? source.toString(); - } - return source?.toString() ?? ''; - } - - void _updateThreadSummaryName(String threadId, String name) { - final index = threadHistory.indexWhere((item) => item.id == threadId); - if (index < 0) { - return; - } - final current = threadHistory[index]; - threadHistory[index] = ThreadSummary( - id: current.id, - preview: current.preview, - cwd: current.cwd, - source: current.source, - modelProvider: current.modelProvider, - createdAt: current.createdAt, - updatedAt: current.updatedAt, - status: current.status, - name: name, - agentNickname: current.agentNickname, - agentRole: current.agentRole, - ); - _sortThreadHistory(); - } - - void _sortThreadHistory() { - threadHistory.sort((a, b) { - final aFavorite = isThreadFavorite(a.id); - final bFavorite = isThreadFavorite(b.id); - if (aFavorite != bFavorite) { - return aFavorite ? -1 : 1; - } - final aUpdated = a.updatedAt ?? a.createdAt; - final bUpdated = b.updatedAt ?? b.createdAt; - if (aUpdated != null && bUpdated != null) { - return bUpdated.compareTo(aUpdated); - } - if (aUpdated != null) { - return -1; - } - if (bUpdated != null) { - return 1; - } - return a.title.toLowerCase().compareTo(b.title.toLowerCase()); - }); - } - - String _normalizeAbsolutePath(String input) { - final trimmed = input.trim(); - if (trimmed.isEmpty) { - return ''; - } - if (!trimmed.startsWith('/')) { - return ''; - } - if (trimmed.length > 1 && trimmed.endsWith('/')) { - return trimmed.substring(0, trimmed.length - 1); - } - return trimmed; - } - - Future _unsubscribeFromThread(String threadId) async { - final normalizedThreadId = threadId.trim(); - if (normalizedThreadId.isEmpty || !isConnected) { - return; - } - if (_subscribedThreadId != normalizedThreadId && - activeThreadId != normalizedThreadId) { - return; - } - try { - await _request('thread/unsubscribe', { - 'threadId': normalizedThreadId, - }); - } catch (_) { - // Best-effort cleanup. A failed unsubscribe should not block switching threads. - } finally { - if (_subscribedThreadId == normalizedThreadId) { - _subscribedThreadId = null; - } - } - } - - bool _isLikelyHumanReadableFile(String path, Uint8List bytes) { - const textExtensions = { - 'txt', - 'md', - 'markdown', - 'json', - 'yaml', - 'yml', - 'toml', - 'xml', - 'html', - 'css', - 'js', - 'ts', - 'tsx', - 'jsx', - 'dart', - 'kt', - 'java', - 'swift', - 'm', - 'mm', - 'c', - 'cc', - 'cpp', - 'h', - 'hpp', - 'rs', - 'go', - 'py', - 'rb', - 'php', - 'sh', - 'zsh', - 'bash', - 'fish', - 'sql', - 'csv', - 'log', - 'ini', - 'cfg', - 'conf', - 'env', - 'gitignore', - 'pubspec', - 'lock', - }; - - final segments = path.split('/'); - final fileName = segments.isEmpty ? path : segments.last; - final extension = fileName.contains('.') - ? fileName.split('.').last.toLowerCase() - : fileName.toLowerCase(); - if (textExtensions.contains(extension)) { - return true; - } - - if (bytes.isEmpty) { - return true; - } - - var suspicious = 0; - final sampleSize = bytes.length > 1024 ? 1024 : bytes.length; - for (var i = 0; i < sampleSize; i += 1) { - final unit = bytes[i]; - if (unit == 0) { - return false; - } - final isControl = unit < 32 && unit != 9 && unit != 10 && unit != 13; - if (isControl) { - suspicious += 1; - } - } - return suspicious <= sampleSize * 0.02; - } - - void _hydrateEntriesFromThread(Map thread) { - final hydratedEntries = []; - final turns = thread['turns']; - if (turns is List) { - for (final turn in turns) { - if (turn is! Map) { - continue; - } - final items = turn['items']; - if (items is! List) { - continue; - } - for (final item in items) { - final entry = _entryFromHistoryItem(item); - if (entry != null) { - hydratedEntries.add(entry); - } - } - } - } - - entries - ..clear() - ..addAll(hydratedEntries); - approvals.clear(); - _entryByItemId - ..clear() - ..addEntries( - hydratedEntries.map( - (item) => MapEntry(item.key, item), - ), - ); - activeTurnId = null; - activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; - } - - void _hydrateResumeTurn(Map turn) { - final turnId = turn['id']?.toString(); - if (turnId == null || turnId.isEmpty) { - return; - } - - final items = turn['items']; - if (items is List) { - for (final item in items) { - final itemMap = item is Map ? item : null; - if (itemMap == null) { - continue; - } - _handleItem( - itemMap, - isCompleted: turn['status']?.toString() != 'inProgress', - turnId: turnId, - ); - } - } - - if (turn['status']?.toString() == 'inProgress') { - activeTurnId = turnId; - final threadId = activeThreadId?.trim() ?? ''; - if (threadId.isNotEmpty) { - _activeTurnIdsByThread[threadId] = turnId; - } - statusMessage = 'Turn running'; - } else if (activeTurnId == turnId) { - activeTurnId = null; - final threadId = activeThreadId?.trim() ?? ''; - if (threadId.isNotEmpty) { - _activeTurnIdsByThread.remove(threadId); - } - statusMessage = 'Ready'; - } - } - - ActivityEntry? _entryFromHistoryItem(dynamic item) { - if (item is! Map) { - return null; - } - final itemId = item['id']?.toString(); - final type = item['type']?.toString() ?? 'unknown'; - if (itemId == null || itemId.isEmpty) { - return null; - } - - final entry = ActivityEntry( - key: itemId, - kind: switch (type) { - 'userMessage' => EntryKind.user, - 'agentMessage' => EntryKind.agent, - 'reasoning' => EntryKind.reasoning, - 'commandExecution' => EntryKind.command, - 'fileChange' => EntryKind.fileChange, - 'mcpToolCall' || - 'collabAgentToolCall' || - 'dynamicToolCall' || - 'webSearch' || - 'plan' => EntryKind.tool, - _ => EntryKind.system, - }, - title: switch (type) { - 'userMessage' => 'You', - 'agentMessage' => 'Codex', - 'reasoning' => 'Reasoning', - 'commandExecution' => item['command']?.toString() ?? 'Command', - 'fileChange' => 'File change', - 'mcpToolCall' => 'MCP tool', - 'collabAgentToolCall' => 'Collaboration', - 'dynamicToolCall' => 'Dynamic tool', - 'webSearch' => 'Web search', - 'plan' => 'Plan', - _ => type, - }, - secondary: item['cwd']?.toString() ?? '', - status: item['status']?.toString() ?? '', - ); - - switch (type) { - case 'userMessage': - entry.body = _extractUserText(item['content']); - case 'agentMessage': - entry.body = item['text']?.toString() ?? ''; - case 'reasoning': - entry.body = _extractReasoningText(item); - case 'commandExecution': - entry.body = item['aggregatedOutput']?.toString() ?? ''; - case 'fileChange': - entry.body = _extractFileChanges(item['changes']); - default: - entry.body = _summarizeMap(item); - } - - return entry; - } - - static Future _defaultOpenPath(String path) async { - if (Platform.isAndroid) { - final normalizedPath = path.trim().toLowerCase(); - if (normalizedPath.isNotEmpty && - (normalizedPath.startsWith('/storage/') || - normalizedPath.startsWith('/sdcard/'))) { - final storageStatus = await Permission.manageExternalStorage.status; - if (!storageStatus.isGranted) { - final requested = await Permission.manageExternalStorage.request(); - if (!requested.isGranted) { - await openAppSettings(); - return false; - } - } - } - if (normalizedPath.endsWith('.apk')) { - final installStatus = await Permission.requestInstallPackages.status; - if (!installStatus.isGranted) { - final requested = await Permission.requestInstallPackages.request(); - if (!requested.isGranted) { - await openAppSettings(); - return false; - } - } - } - } - final result = await OpenFilex.open(path); - return result.type == ResultType.done; - } - - _RelayPairingCodePayload _decodeRelayPairingCode(String value) { - const prefix = 'crp1.'; - final trimmed = value.trim(); - if (!trimmed.startsWith(prefix)) { - throw StateError('Unsupported pairing code format.'); - } - final payload = - jsonDecode(utf8.decode(_b64urlDecode(trimmed.substring(prefix.length)))) - as Map; - if (payload['type']?.toString() != 'codex-remote-pairing-v1') { - throw StateError('Unsupported pairing code payload.'); - } - final relayUrl = payload['relayUrl']?.toString() ?? ''; - final deviceId = payload['deviceId']?.toString() ?? ''; - final claimToken = payload['claimToken']?.toString() ?? ''; - final bridgeSigningPublicKey = - payload['bridgeSigningPublicKey']?.toString() ?? ''; - final bridgeLabel = payload['bridgeLabel']?.toString() ?? ''; - final expiresAt = payload['expiresAt'] as int? ?? 0; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final relayUri = Uri.tryParse(relayUrl); - if (relayUrl.isEmpty || - deviceId.isEmpty || - claimToken.isEmpty || - bridgeSigningPublicKey.isEmpty) { - throw StateError('Pairing code is incomplete.'); - } - if (relayUri == null || !relayUri.hasScheme || relayUri.host.isEmpty) { - throw StateError('Pairing code contains an invalid relay URL.'); - } - if (!_isAllowedRelayUri(relayUri)) { - throw StateError( - 'Relay pairing requires HTTPS for non-local relay servers.', - ); - } - if (expiresAt != 0 && expiresAt < now) { - throw StateError('Pairing code has expired.'); - } - return _RelayPairingCodePayload( - bridgeLabel: bridgeLabel, - bridgeSigningPublicKey: bridgeSigningPublicKey, - claimToken: claimToken, - deviceId: deviceId, - relayUrl: relayUrl, - ); - } -} - -class _RelayPairingCodePayload { - const _RelayPairingCodePayload({ - required this.bridgeLabel, - required this.bridgeSigningPublicKey, - required this.claimToken, - required this.deviceId, - required this.relayUrl, - }); - - final String bridgeLabel; - final String bridgeSigningPublicKey; - final String claimToken; - final String deviceId; - final String relayUrl; -} - -String _b64urlEncode(List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); -} - -Uint8List _b64urlDecode(String value) { - final normalized = value.padRight((value.length + 3) ~/ 4 * 4, '='); - return Uint8List.fromList(base64Url.decode(normalized)); -} - -bool _isAllowedRelayUri(Uri relayUri) { - if (relayUri.scheme == 'https') { - return true; - } - if (relayUri.scheme != 'http') { - return false; - } - final host = relayUri.host.toLowerCase(); - return host == 'localhost' || - host == '127.0.0.1' || - host == '::1' || - host.endsWith('.local'); -} - -class _ActiveAutomationWatch { - const _ActiveAutomationWatch({ - required this.automationId, - required this.watchId, - required this.path, - required this.kind, - }); - - final String automationId; - final String watchId; - final String path; - final AutomationNodeKind kind; -} - -class _RegisteredAutomationWatch { - const _RegisteredAutomationWatch({required this.watchId, required this.path}); - - final String watchId; - final String path; -} - -class _AutomationExecutionContext { - _AutomationExecutionContext({ - required this.changedPaths, - required this.watchedPath, - required this.triggerKind, - }); - - final List changedPaths; - final String watchedPath; - final AutomationNodeKind triggerKind; - String? lastDownloadedPath; - final Map> nodeOutputs = - >{}; - String? _previousNodeId; - - void recordNodeOutput(String nodeId, Map values) { - nodeOutputs[nodeId] = values; - _previousNodeId = nodeId; - final downloadedPath = values['downloadedPath']?.trim() ?? ''; - if (downloadedPath.isNotEmpty) { - lastDownloadedPath = downloadedPath; - } - } - - String? valueForToken(String token) { - if (token.isEmpty) { - return null; - } - final firstChangedPath = changedPaths.isEmpty ? '' : changedPaths.first; - switch (token) { - case 'trigger.path': - case 'trigger.watchedPath': - return watchedPath; - case 'trigger.changedPath': - return firstChangedPath; - case 'automation.lastDownloadedPath': - case 'lastDownloadedPath': - return lastDownloadedPath; - } - - if (token.startsWith('previous.')) { - final previousNodeId = _previousNodeId; - if (previousNodeId == null) { - return null; - } - return nodeOutputs[previousNodeId]?[token.substring('previous.'.length)]; - } - - if (token.startsWith('node.')) { - final parts = token.split('.'); - if (parts.length >= 3) { - final nodeId = parts[1]; - final key = parts.sublist(2).join('.'); - return nodeOutputs[nodeId]?[key]; - } - } - - return null; - } -} - -class _PendingDownload { - _PendingDownload({required this.expectedBytes, required this.onProgress}) - : _startedAt = DateTime.now(); - - final int? expectedBytes; - final ValueChanged? onProgress; - final DateTime _startedAt; - String stderr = ''; - bool isCancelled = false; - int writtenBytes = 0; - bool _processExited = false; - final Completer _completion = Completer(); - - void cancel() { - isCancelled = true; - _completeIfReady(); - } - - void markProcessExited() { - _processExited = true; - _completeIfReady(); - } - - void reportProgress() { - final callback = onProgress; - if (callback == null) { - _completeIfReady(); - return; - } - final total = expectedBytes; - if (total == null || total <= 0) { - callback( - FileDownloadStatus( - progress: 0.8, - receivedBytes: writtenBytes, - totalBytes: null, - eta: null, - ), - ); - _completeIfReady(); - return; - } - final progress = writtenBytes / total; - final safeProgress = progress.clamp(0.0, 0.95); - final elapsed = DateTime.now().difference(_startedAt); - Duration? eta; - if (writtenBytes > 0 && - elapsed.inMilliseconds > 0 && - writtenBytes < total) { - final bytesPerMs = writtenBytes / elapsed.inMilliseconds; - if (bytesPerMs > 0) { - final remainingMs = ((total - writtenBytes) / bytesPerMs).round(); - eta = Duration(milliseconds: remainingMs); - } - } - callback( - FileDownloadStatus( - progress: safeProgress, - receivedBytes: writtenBytes, - totalBytes: total, - eta: eta, - ), - ); - _completeIfReady(); - } - - void addBytes(int count) { - writtenBytes += count; - reportProgress(); - } - - Future waitForCompletion() async { - _completeIfReady(); - if (_completion.isCompleted) { - return; - } - await _completion.future.timeout( - const Duration(seconds: 5), - onTimeout: () { - if (_completion.isCompleted) { - return; - } - if (isCancelled) { - _completion.complete(); - return; - } - final total = expectedBytes; - if (total != null && total > 0 && writtenBytes != total) { - _completion.completeError( - StateError( - 'Download truncated: expected $total bytes, received $writtenBytes bytes.', - ), - ); - return; - } - _completion.complete(); - }, - ); - } - - void _completeIfReady() { - if (_completion.isCompleted) { - return; - } - if (isCancelled) { - _completion.complete(); - return; - } - if (!_processExited) { - return; - } - final total = expectedBytes; - if (total != null && total > 0) { - if (writtenBytes >= total) { - _completion.complete(); - } - return; - } - _completion.complete(); - } -} - -class _PendingTransferServer { - final Completer<_TransferEndpoint> _ready = Completer<_TransferEndpoint>(); - final StringBuffer _stdoutBuffer = StringBuffer(); - String stderr = ''; - - void handleStdout(String chunk) { - _stdoutBuffer.write(chunk); - final lines = _stdoutBuffer.toString().split('\n'); - if (!chunk.endsWith('\n')) { - final trailing = lines.removeLast(); - _stdoutBuffer - ..clear() - ..write(trailing); - } else { - _stdoutBuffer.clear(); - } - for (final line in lines) { - final trimmed = line.trim(); - if (trimmed.isEmpty) { - continue; - } - try { - final decoded = jsonDecode(trimmed); - if (decoded is Map && - decoded['event'] == 'ready' && - decoded['port'] is int && - decoded['token'] is String && - !_ready.isCompleted) { - _ready.complete( - _TransferEndpoint( - port: decoded['port'] as int, - token: decoded['token'] as String, - ), - ); - return; - } - } catch (_) { - // Ignore unrelated command output. - } - } - } - - Future<_TransferEndpoint> waitForReady() { - return _ready.future.timeout( - const Duration(seconds: 10), - onTimeout: () { - throw TimeoutException( - 'Temporary download server did not become ready.', - const Duration(seconds: 10), - ); - }, - ); - } -} - -class FileDownloadStatus { - const FileDownloadStatus({ - required this.progress, - required this.receivedBytes, - required this.totalBytes, - required this.eta, - }); - - final double progress; - final int receivedBytes; - final int? totalBytes; - final Duration? eta; -} - -enum DownloadState { running, completed, failed, cancelled } - -class DownloadRecord { - const DownloadRecord({ - required this.sourcePath, - required this.fileName, - required this.state, - required this.startedAt, - this.status, - this.targetPath, - this.error, - this.finishedAt, - }); - - final String sourcePath; - final String fileName; - final String? targetPath; - final DownloadState state; - final FileDownloadStatus? status; - final String? error; - final DateTime startedAt; - final DateTime? finishedAt; - - DownloadRecord copyWith({ - String? sourcePath, - String? fileName, - String? targetPath, - DownloadState? state, - FileDownloadStatus? status, - String? error, - DateTime? startedAt, - DateTime? finishedAt, - }) { - return DownloadRecord( - sourcePath: sourcePath ?? this.sourcePath, - fileName: fileName ?? this.fileName, - targetPath: targetPath ?? this.targetPath, - state: state ?? this.state, - status: status ?? this.status, - error: error ?? this.error, - startedAt: startedAt ?? this.startedAt, - finishedAt: finishedAt ?? this.finishedAt, - ); - } -} - -class _DirectoryCacheEntry { - const _DirectoryCacheEntry({required this.entries, required this.loadedAt}); - - final List entries; - final DateTime loadedAt; -} - -class _FilePreviewCacheEntry { - const _FilePreviewCacheEntry({ - required this.bytes, - required this.content, - required this.isHumanReadable, - required this.loadedAt, - }); - - final Uint8List bytes; - final String? content; - final bool isHumanReadable; - final DateTime loadedAt; -} - -class _TransferEndpoint { - const _TransferEndpoint({required this.port, required this.token}); - - final int port; - final String token; -} - -class _DownloadCancelled implements Exception { - const _DownloadCancelled(); -} +export 'core/infrastructure/app_controller.dart'; diff --git a/lib/src/core/infrastructure/app_controller.dart b/lib/src/core/infrastructure/app_controller.dart new file mode 100644 index 0000000..b9f5c85 --- /dev/null +++ b/lib/src/core/infrastructure/app_controller.dart @@ -0,0 +1,5163 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/widgets.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../models.dart'; +import '../../settings_store.dart'; +import '../../transport.dart'; + +class AppController extends ChangeNotifier with WidgetsBindingObserver { + static const Duration _directoryReadTimeout = Duration(minutes: 2); + + AppController._( + this._settingsStore, + this._settings, + this._transport, + this._httpClientFactory, + this._openPath, + this._automationFsQuietPeriod, + ) { + automations.addAll(_settings.automations); + } + + static Future bootstrap() async { + final store = SettingsStore(); + final settings = await store.load(); + final recentCommands = await store.loadRecentCommands(); + final controller = AppController._( + store, + settings, + createDefaultTransport(), + () => HttpClient(), + _defaultOpenPath, + const Duration(milliseconds: 1500), + ); + controller.recentCommands.addAll(recentCommands); + WidgetsBinding.instance.addObserver(controller); + return controller; + } + + @visibleForTesting + factory AppController.testing({ + AppTransport? transport, + HttpClient Function()? httpClientFactory, + Future Function(String path)? openPath, + Duration automationFsQuietPeriod = const Duration(milliseconds: 20), + }) { + return AppController._( + SettingsStore(), + AppSettings.defaults(), + transport ?? DirectWebSocketTransport(), + httpClientFactory ?? (() => HttpClient()), + openPath ?? _defaultOpenPath, + automationFsQuietPeriod, + ); + } + + final SettingsStore _settingsStore; + final AppTransport _transport; + final HttpClient Function() _httpClientFactory; + final Future Function(String path) _openPath; + final Duration _automationFsQuietPeriod; + AppSettings _settings; + + AppSettings get settings => _settings; + + ConnectionStatus status = ConnectionStatus.disconnected; + String statusMessage = 'Disconnected'; + String? activeThreadId; + String? activeThreadName; + String activeThreadCwd = ''; + String? _subscribedThreadId; + String? activeTurnId; + final Map _activeTurnIdsByThread = {}; + bool isLoadingHistory = false; + String? openingThreadId; + bool isLoadingFiles = false; + bool isLoadingFilePreview = false; + bool isSavingFilePreview = false; + bool isLoadingModels = false; + String? threadHistoryError; + String? fileBrowserError; + String? filePreviewSaveError; + String? modelListError; + String? rateLimitSummary; + List rateLimitResetDetails = const []; + String? contextWindowSummary; + int? contextUsagePercent; + String? _threadHistoryCursor; + String? activeCommandSessionId; + bool isSteering = false; + String fileBrowserPath = ''; + String? selectedFilePath; + String? selectedFileContent; + Uint8List? selectedFileBytes; + bool selectedFileIsHumanReadable = false; + int? selectedFileHighlightedLine; + final Map _fileDownloadStatusByPath = + {}; + final Map _fileDownloadProcessIdByPath = {}; + final Map _directoryCache = + {}; + final Map _filePreviewCache = + {}; + DateTime? _threadHistoryLoadedAt; + DateTime? _modelOptionsLoadedAt; + + final List entries = []; + final List approvals = []; + final List eventLog = []; + final List threadHistory = []; + final List fileBrowserEntries = []; + final List modelOptions = []; + final List automations = []; + final List commandSessions = []; + final List recentCommands = []; + final List pendingPrompts = []; + final List downloadRecords = []; + + final Map _entryByItemId = {}; + final List _pendingOptimisticUserEntryKeys = []; + final Map _commandSessionsById = + {}; + final Map _commandSessionsByProcessId = + {}; + final Map _pendingDownloadsByProcessId = + {}; + final Map _pendingTransferServersByProcessId = + {}; + final Map> _uploadedImagePathsByTurnId = + >{}; + final Map _activeAutomationWatches = + {}; + final Map _registeredAutomationWatches = + {}; + final Map _pendingCommandRequestsById = + {}; + final Map?>> _pendingRequests = + ?>>{}; + + StreamSubscription? _subscription; + int _requestId = 1; + bool _manualDisconnect = false; + bool _shouldReconnectOnResume = false; + bool _isInBackground = false; + String? _pendingNewThreadCwd; + final Set _runningAutomationIds = {}; + final Map> _queuedAutomationChangedPaths = + >{}; + final Map _automationDebounceTimers = {}; + final Map> _debouncedAutomationChangedPaths = + >{}; + Completer? _automationWatchSyncCompleter; + bool _automationWatchSyncQueued = false; + + bool get isConnected => status == ConnectionStatus.ready; + bool get hasActiveTurn => activeTurnId != null; + bool get hasMoreThreadHistory => _threadHistoryCursor != null; + bool get isOpeningThread => openingThreadId != null; + int get queuedPromptCount => pendingPrompts.length; + List get queuedPrompts => + pendingPrompts.map((item) => item.text).toList(); + String? get composerMetaLeftText { + final value = rateLimitSummary?.trim() ?? ''; + return value.isEmpty ? null : value; + } + + bool get hasRateLimitResetDetails => rateLimitResetDetails.isNotEmpty; + + String? get composerMetaRightText { + final value = contextWindowSummary?.trim() ?? ''; + return value.isEmpty ? null : value; + } + + String get preferredCommandCwd { + final threadCwd = activeThreadCwd.trim(); + if (threadCwd.isNotEmpty) { + return threadCwd; + } + return _pendingNewThreadCwd?.trim() ?? ''; + } + + String get preferredFileBrowserRoot { + final commandCwd = preferredCommandCwd; + if (commandCwd.isNotEmpty) { + return commandCwd; + } + return '/'; + } + + Duration get _threadLoadTimeout { + final timeoutMs = _settings.threadLoadTimeoutMs; + if (timeoutMs <= 0) { + return const Duration(seconds: 20); + } + return Duration(milliseconds: timeoutMs); + } + + bool get needsThreadDirectorySelection { + return activeThreadId == null && + _settings.resumeThreadId.trim().isEmpty && + (_pendingNewThreadCwd?.trim().isEmpty ?? true); + } + + String? _preferredDownloadDirectoryForThread(String threadId) { + if (threadId.trim().isEmpty) { + return null; + } + final value = + _settings.threadDownloadDirectories[threadId.trim()]?.trim() ?? ''; + return value.isEmpty ? null : value; + } + + Future _rememberDownloadDirectoryForThread( + String threadId, + String directory, + ) async { + final normalizedThreadId = threadId.trim(); + final normalizedDirectory = directory.trim(); + if (normalizedThreadId.isEmpty || normalizedDirectory.isEmpty) { + return; + } + final nextDirectories = Map.from( + _settings.threadDownloadDirectories, + ); + nextDirectories[normalizedThreadId] = normalizedDirectory; + await saveSettings( + _settings.copyWith(threadDownloadDirectories: nextDirectories), + ); + } + + bool isFileDownloading(String path) { + final normalizedPath = _normalizeAbsolutePath(path); + return _fileDownloadStatusByPath.containsKey(normalizedPath); + } + + double? fileDownloadProgress(String path) { + final normalizedPath = _normalizeAbsolutePath(path); + return _fileDownloadStatusByPath[normalizedPath]?.progress; + } + + FileDownloadStatus? fileDownloadStatus(String path) { + final normalizedPath = _normalizeAbsolutePath(path); + return _fileDownloadStatusByPath[normalizedPath]; + } + + int get activeDownloadCount => downloadRecords + .where((item) => item.state == DownloadState.running) + .length; + + bool get hasDownloads => downloadRecords.isNotEmpty; + bool threadHasActiveTurn(String threadId) { + final normalized = threadId.trim(); + if (normalized.isEmpty) { + return false; + } + return _activeTurnIdsByThread.containsKey(normalized); + } + + bool isAutomationRunning(String automationId) { + return _runningAutomationIds.contains(automationId); + } + + bool isThreadFavorite(String threadId) { + return _settings.favoriteThreadIds.contains(threadId.trim()); + } + + String get currentAutomationScopeThreadId { + final active = activeThreadId?.trim() ?? ''; + if (active.isNotEmpty) { + return active; + } + return _settings.resumeThreadId.trim(); + } + + bool isAutomationVisibleInCurrentThread(AutomationDefinition automation) { + final ownerThreadId = automation.ownerThreadId.trim(); + if (ownerThreadId.isEmpty) { + return true; + } + final scopeThreadId = currentAutomationScopeThreadId; + if (scopeThreadId.isEmpty) { + return false; + } + return ownerThreadId == scopeThreadId; + } + + void _resyncAutomationWatchesForCurrentThread() { + if (isConnected) { + unawaited(_syncAutomationWatches()); + } + } + + Future toggleFavoriteThread(String threadId) async { + final normalizedThreadId = threadId.trim(); + if (normalizedThreadId.isEmpty) { + return; + } + final nextFavorites = List.from(_settings.favoriteThreadIds); + if (nextFavorites.contains(normalizedThreadId)) { + nextFavorites.removeWhere((item) => item == normalizedThreadId); + } else { + nextFavorites.insert(0, normalizedThreadId); + } + await saveSettings(_settings.copyWith(favoriteThreadIds: nextFavorites)); + _sortThreadHistory(); + notifyListeners(); + } + + Future saveSettings(AppSettings nextSettings) async { + final previousModel = _settings.model.trim(); + final previousAutomations = jsonEncode( + _settings.automations + .map((item) => item.toJson()) + .toList(growable: false), + ); + _settings = nextSettings; + automations + ..clear() + ..addAll(_settings.automations); + await _settingsStore.save(_settings); + notifyListeners(); + final nextModel = nextSettings.model.trim(); + final nextAutomations = jsonEncode( + _settings.automations + .map((item) => item.toJson()) + .toList(growable: false), + ); + if (isConnected && previousModel != nextModel) { + unawaited(_refreshUsageMetadata()); + } + if (isConnected && previousAutomations != nextAutomations) { + await _syncAutomationWatches(); + } + } + + Future clearThreadState() async { + final previousThreadId = activeThreadId?.trim() ?? ''; + if (previousThreadId.isNotEmpty) { + await _unsubscribeFromThread(previousThreadId); + } + activeThreadId = null; + activeThreadName = null; + activeThreadCwd = ''; + activeTurnId = null; + _activeTurnIdsByThread.clear(); + contextUsagePercent = null; + isSteering = false; + entries.clear(); + approvals.clear(); + _entryByItemId.clear(); + _pendingOptimisticUserEntryKeys.clear(); + pendingPrompts.clear(); + _pendingNewThreadCwd = null; + await saveSettings(_settings.copyWith(resumeThreadId: '')); + _addSystemEntry( + 'Started a new local session. The next prompt will open a new thread.', + ); + } + + Future connect({bool preserveAutomationWatches = true}) async { + await disconnect( + clearUiState: false, + manual: false, + preserveAutomationWatches: preserveAutomationWatches, + ); + _manualDisconnect = false; + status = ConnectionStatus.connecting; + statusMessage = 'Connecting'; + notifyListeners(); + + try { + _subscription = _transport.messages.listen( + _handleSocketMessage, + onError: (Object error, StackTrace stackTrace) { + status = ConnectionStatus.error; + statusMessage = 'Connection error'; + _shouldReconnectOnResume = !_manualDisconnect; + _addSystemEntry('Websocket error: $error'); + notifyListeners(); + }, + onDone: () { + if (status != ConnectionStatus.disconnected) { + status = ConnectionStatus.disconnected; + statusMessage = 'Disconnected'; + activeTurnId = null; + _shouldReconnectOnResume = !_manualDisconnect; + notifyListeners(); + } + }, + ); + await _transport.connect(_settings); + + status = ConnectionStatus.initializing; + statusMessage = 'Initializing'; + notifyListeners(); + + await _request('initialize', { + 'clientInfo': { + 'name': 'codex_remote_flutter', + 'title': 'Codex Remote', + 'version': '1.0.0', + }, + }); + + _notify('initialized'); + status = ConnectionStatus.ready; + statusMessage = 'Ready'; + _shouldReconnectOnResume = true; + _addSystemEntry('Connected to ${_settings.activeConnectionLabel}.'); + await _refreshUsageMetadata(notify: false); + await loadModelOptions(force: true); + await _syncAutomationWatches(); + notifyListeners(); + } catch (error) { + status = ConnectionStatus.error; + statusMessage = 'Failed to connect'; + _shouldReconnectOnResume = !_manualDisconnect; + _addSystemEntry('Connection failed: $error'); + notifyListeners(); + } + } + + Future disconnect({ + bool clearUiState = false, + bool manual = true, + bool preserveAutomationWatches = false, + }) async { + _manualDisconnect = manual; + if (manual) { + _shouldReconnectOnResume = false; + } + await _subscription?.cancel(); + _subscription = null; + await _transport.disconnect(); + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError(StateError('Connection closed')); + } + } + _pendingRequests.clear(); + status = ConnectionStatus.disconnected; + statusMessage = 'Disconnected'; + activeTurnId = null; + _activeTurnIdsByThread.clear(); + openingThreadId = null; + _subscribedThreadId = null; + rateLimitSummary = null; + contextWindowSummary = null; + contextUsagePercent = null; + if (!preserveAutomationWatches) { + _activeAutomationWatches.clear(); + _registeredAutomationWatches.clear(); + } + _runningAutomationIds.clear(); + _queuedAutomationChangedPaths.clear(); + if (clearUiState) { + activeThreadName = null; + activeThreadCwd = ''; + entries.clear(); + approvals.clear(); + _entryByItemId.clear(); + _pendingOptimisticUserEntryKeys.clear(); + } + notifyListeners(); + } + + Future reconnectWithSettings(AppSettings nextSettings) async { + final modeChanged = nextSettings.connectionMode != _settings.connectionMode; + final urlChanged = nextSettings.serverUrl != _settings.serverUrl; + final authChanged = + nextSettings.websocketBearerToken.trim() != + _settings.websocketBearerToken.trim(); + final relayChanged = + nextSettings.relayUrl.trim() != _settings.relayUrl.trim() || + nextSettings.relayDeviceId.trim() != _settings.relayDeviceId.trim() || + nextSettings.relayClientPrivateKey.trim() != + _settings.relayClientPrivateKey.trim() || + nextSettings.relayClientPublicKey.trim() != + _settings.relayClientPublicKey.trim() || + nextSettings.relayBridgeSigningPublicKey.trim() != + _settings.relayBridgeSigningPublicKey.trim(); + await saveSettings(nextSettings); + if ((modeChanged || urlChanged || authChanged || relayChanged) && + status != ConnectionStatus.disconnected) { + await connect(preserveAutomationWatches: false); + } + } + + Future pairRelayDevice({ + required String pairingCode, + String clientLabel = 'Codex Remote', + }) async { + final decoded = _decodeRelayPairingCode(pairingCode); + final signing = Ed25519(); + final keyPair = await signing.newKeyPair(); + final keyPairData = await keyPair.extract(); + final publicKey = await keyPair.extractPublicKey(); + final request = await _httpClientFactory().postUrl( + Uri.parse('${decoded.relayUrl}/api/v1/device/claim'), + ); + request.headers.contentType = ContentType.json; + request.write( + jsonEncode({ + 'pairingCode': pairingCode.trim(), + 'clientLabel': clientLabel.trim().isEmpty + ? 'Codex Remote' + : clientLabel.trim(), + 'clientSigningPublicKey': _b64urlEncode(publicKey.bytes), + }), + ); + final response = await request.close(); + final responseBody = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError( + 'Relay pairing failed: ${response.statusCode} $responseBody', + ); + } + final payload = jsonDecode(responseBody) as Map; + await saveSettings( + _settings.copyWith( + connectionMode: ConnectionMode.relay, + relayUrl: decoded.relayUrl, + relayDeviceId: decoded.deviceId, + relayBridgeLabel: + payload['bridgeLabel']?.toString() ?? decoded.bridgeLabel, + relayBridgeSigningPublicKey: + payload['bridgeSigningPublicKey']?.toString() ?? + decoded.bridgeSigningPublicKey, + relayClientPrivateKey: _b64urlEncode(keyPairData.bytes), + relayClientPublicKey: _b64urlEncode(publicKey.bytes), + ), + ); + } + + Future clearRelayPairing() async { + await saveSettings( + _settings.copyWith( + connectionMode: ConnectionMode.direct, + relayUrl: '', + relayDeviceId: '', + relayBridgeLabel: '', + relayBridgeSigningPublicKey: '', + relayClientPrivateKey: '', + relayClientPublicKey: '', + ), + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + final shouldReconnect = + _isInBackground && + _shouldReconnectOnResume && + !_manualDisconnect && + !_transport.isConnected && + (status == ConnectionStatus.disconnected || + status == ConnectionStatus.error); + _isInBackground = false; + if (shouldReconnect) { + unawaited(_reconnectAfterResume()); + } + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + _isInBackground = true; + case AppLifecycleState.detached: + _isInBackground = false; + } + } + + Future _reconnectAfterResume() async { + if (_transport.isConnected) { + if (status != ConnectionStatus.ready) { + status = ConnectionStatus.ready; + statusMessage = 'Ready'; + notifyListeners(); + } + return; + } + await connect(); + if (!isConnected) { + return; + } + + final threadId = activeThreadId ?? _settings.resumeThreadId.trim(); + if (threadId.isEmpty) { + return; + } + + try { + final response = await _request('thread/resume', { + 'threadId': threadId, + }, _threadLoadTimeout); + final thread = response?['thread']; + if (thread is Map) { + activeThreadId = thread['id']?.toString() ?? threadId; + _subscribedThreadId = activeThreadId; + activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; + activeThreadName = thread['name']?.toString() ?? activeThreadName; + await saveSettings( + _settings.copyWith(resumeThreadId: activeThreadId ?? threadId), + ); + _resyncAutomationWatchesForCurrentThread(); + } + _addSystemEntry('Reconnected after returning to the app.'); + } catch (error) { + _addSystemEntry('Reconnect after resume failed: $error'); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future startFreshThreadInDirectory(String cwd) async { + await clearThreadState(); + _pendingNewThreadCwd = cwd.trim(); + if (isConnected) { + await _ensureThread(forceNew: true); + } + } + + Future saveAutomation(AutomationDefinition automation) async { + final next = List.from(automations); + final scopeThreadId = currentAutomationScopeThreadId; + final normalizedAutomation = automation.copyWith( + ownerThreadId: automation.ownerThreadId.trim().isEmpty + ? scopeThreadId + : automation.ownerThreadId.trim(), + ); + final index = next.indexWhere((item) => item.id == automation.id); + if (index >= 0) { + next[index] = normalizedAutomation; + } else { + next.insert(0, normalizedAutomation); + } + await saveSettings(_settings.copyWith(automations: next)); + } + + Future copyAutomationToCurrentThread(String automationId) async { + AutomationDefinition? source; + for (final automation in automations) { + if (automation.id == automationId) { + source = automation; + break; + } + } + if (source == null) { + return; + } + final copiedAutomation = source.copyWith( + id: 'automation-${DateTime.now().microsecondsSinceEpoch}', + ownerThreadId: currentAutomationScopeThreadId, + nodes: source.nodes + .map( + (node) => node.copyWith( + id: 'node-${DateTime.now().microsecondsSinceEpoch}-${node.id}', + ), + ) + .toList(growable: false), + ); + await saveAutomation(copiedAutomation); + } + + Future deleteAutomation(String automationId) async { + final next = automations + .where((item) => item.id != automationId) + .toList(growable: false); + await saveSettings(_settings.copyWith(automations: next)); + } + + Future setAutomationEnabled(String automationId, bool enabled) async { + final next = automations + .map((item) { + if (item.id != automationId) { + return item; + } + return item.copyWith(enabled: enabled); + }) + .toList(growable: false); + await saveSettings(_settings.copyWith(automations: next)); + } + + Future startCommandExecution({ + required String commandText, + required String cwd, + required SandboxMode sandboxMode, + required bool allowNetwork, + required CommandSessionMode mode, + required int timeoutMs, + required bool disableTimeout, + required int outputBytesCap, + required bool disableOutputCap, + int rows = 20, + int cols = 80, + }) async { + await _startCommandExecutionInternal( + commandText: commandText, + cwd: cwd, + sandboxMode: sandboxMode, + allowNetwork: allowNetwork, + mode: mode, + timeoutMs: timeoutMs, + disableTimeout: disableTimeout, + outputBytesCap: outputBytesCap, + disableOutputCap: disableOutputCap, + rows: rows, + cols: cols, + rememberRecent: true, + awaitCompletion: false, + ); + } + + Future _startCommandExecutionInternal({ + required String commandText, + required String cwd, + required SandboxMode sandboxMode, + required bool allowNetwork, + required CommandSessionMode mode, + required int timeoutMs, + required bool disableTimeout, + required int outputBytesCap, + required bool disableOutputCap, + required bool rememberRecent, + required bool awaitCompletion, + int rows = 20, + int cols = 80, + }) async { + final trimmed = commandText.trim(); + if (trimmed.isEmpty) { + return null; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return null; + } + } + + final processId = 'cmd-${DateTime.now().microsecondsSinceEpoch}'; + final normalizedCwd = cwd.trim().isEmpty ? preferredCommandCwd : cwd.trim(); + final usesTty = _shouldUseTtyForCommand(trimmed, mode); + final session = CommandSession( + id: processId, + processId: processId, + commandDisplay: trimmed, + cwd: normalizedCwd, + mode: mode, + usesTty: usesTty, + startedAt: DateTime.now(), + ); + + if (rememberRecent) { + await _rememberRecentCommand( + RecentCommand( + commandText: trimmed, + cwd: normalizedCwd, + mode: mode, + sandboxMode: sandboxMode, + allowNetwork: allowNetwork, + disableTimeout: disableTimeout, + timeoutMs: timeoutMs, + disableOutputCap: disableOutputCap, + outputBytesCap: outputBytesCap, + ), + ); + } + + commandSessions.insert(0, session); + _commandSessionsById[session.id] = session; + _commandSessionsByProcessId[session.processId] = session; + activeCommandSessionId = session.id; + notifyListeners(); + + final id = _requestId++; + final completer = Completer?>(); + _pendingRequests[id] = completer; + _pendingCommandRequestsById[id] = session; + + final params = { + 'command': ['/bin/bash', '-lc', trimmed], + if (session.cwd.isNotEmpty) 'cwd': session.cwd, + 'processId': processId, + 'streamStdoutStderr': true, + 'sandboxPolicy': _buildCommandSandboxPolicy( + sandboxMode, + allowNetwork, + session.cwd, + ), + if (disableOutputCap) 'disableOutputCap': true, + if (!disableOutputCap && outputBytesCap > 0) + 'outputBytesCap': outputBytesCap, + if (disableTimeout) 'disableTimeout': true, + if (!disableTimeout && timeoutMs > 0) 'timeoutMs': timeoutMs, + if (mode == CommandSessionMode.interactive) ...{ + 'streamStdin': true, + if (usesTty) 'tty': true, + if (usesTty) 'size': {'rows': rows, 'cols': cols}, + }, + }; + + try { + await _send({ + 'id': id, + 'method': 'command/exec', + 'params': params, + }); + } catch (error) { + _pendingRequests.remove(id); + _pendingCommandRequestsById.remove(id); + session.status = 'failed'; + session.stderr = [ + session.stderr.trimRight(), + error.toString(), + ].where((item) => item.isNotEmpty).join('\n'); + notifyListeners(); + if (awaitCompletion) { + rethrow; + } + return session; + } + + if (mode == CommandSessionMode.interactive && usesTty) { + unawaited(_primeInteractiveSessionSize(session, rows: rows, cols: cols)); + } + + final completion = completer.future + .then((Map? result) { + _pendingCommandRequestsById.remove(id); + _completeCommandSession(session, result); + }) + .catchError((Object error) { + _pendingCommandRequestsById.remove(id); + session.status = 'failed'; + session.stderr = [ + session.stderr.trimRight(), + error.toString(), + ].where((item) => item.isNotEmpty).join('\n'); + notifyListeners(); + }); + if (awaitCompletion) { + await completion; + } else { + unawaited(completion); + } + return session; + } + + Future writeToCommandSession( + String sessionId, + String input, { + bool closeStdin = false, + }) async { + final session = _commandSessionsById[sessionId]; + if (session == null || !session.isRunning) { + return; + } + + final payload = input.isEmpty ? null : base64Encode(utf8.encode(input)); + final payloadField = payload == null + ? null + : {'deltaBase64': payload}; + await _request('command/exec/write', { + 'processId': session.processId, + ...?payloadField, + if (closeStdin) 'closeStdin': true, + }); + if (closeStdin) { + session.stdinClosed = true; + notifyListeners(); + } + } + + Future closeCommandSessionStdin(String sessionId) async { + await writeToCommandSession(sessionId, '', closeStdin: true); + } + + Future terminateCommandSession(String sessionId) async { + final session = _commandSessionsById[sessionId]; + if (session == null || !session.isRunning) { + return; + } + + await _request('command/exec/terminate', { + 'processId': session.processId, + }); + } + + Future resizeCommandSession( + String sessionId, { + required int rows, + required int cols, + }) async { + final session = _commandSessionsById[sessionId]; + if (session == null || + !session.isInteractive || + !session.usesTty || + !session.isRunning) { + return; + } + + await _request('command/exec/resize', { + 'processId': session.processId, + 'size': {'rows': rows, 'cols': cols}, + }); + } + + Future _primeInteractiveSessionSize( + CommandSession session, { + required int rows, + required int cols, + }) async { + const delays = [ + Duration.zero, + Duration(milliseconds: 250), + Duration(milliseconds: 1000), + ]; + + for (final delay in delays) { + if (!session.isInteractive || !session.usesTty || !session.isRunning) { + return; + } + if (delay > Duration.zero) { + await Future.delayed(delay); + } + try { + await _request('command/exec/resize', { + 'processId': session.processId, + 'size': {'rows': rows, 'cols': cols}, + }); + } catch (_) { + // Ignore resize failures; later attempts or layout-driven resize may still succeed. + } + } + } + + bool _shouldUseTtyForCommand(String commandText, CommandSessionMode mode) { + if (mode != CommandSessionMode.interactive) { + return false; + } + final trimmed = commandText.trim(); + if (trimmed.isEmpty) { + return true; + } + final prefersPlainStreaming = RegExp( + r'(^|\s)flutter(\s|$)', + ).hasMatch(trimmed); + return !prefersPlainStreaming; + } + + void selectCommandSession(String sessionId) { + if (_commandSessionsById.containsKey(sessionId)) { + activeCommandSessionId = sessionId; + notifyListeners(); + } + } + + Future clearFinishedCommandSessions() async { + commandSessions.removeWhere((session) => !session.isRunning); + _commandSessionsById.removeWhere( + (_, CommandSession session) => !session.isRunning, + ); + _commandSessionsByProcessId.removeWhere( + (_, CommandSession session) => !session.isRunning, + ); + if (activeCommandSessionId != null && + !_commandSessionsById.containsKey(activeCommandSessionId)) { + activeCommandSessionId = commandSessions.isEmpty + ? null + : commandSessions.first.id; + } + notifyListeners(); + } + + Future clearAllCommandSessions() async { + commandSessions.clear(); + _commandSessionsById.clear(); + _commandSessionsByProcessId.clear(); + activeCommandSessionId = null; + notifyListeners(); + } + + Future removeRecentCommand(RecentCommand target) async { + recentCommands.removeWhere( + (command) => _sameRecentCommand(command, target), + ); + await _settingsStore.saveRecentCommands(recentCommands); + notifyListeners(); + } + + Future loadThreadHistory({bool reset = false}) async { + if (isLoadingHistory) { + return; + } + final now = DateTime.now(); + final isFreshCache = + reset && + _threadHistoryLoadedAt != null && + now.difference(_threadHistoryLoadedAt!) < const Duration(seconds: 20) && + threadHistory.isNotEmpty; + if (isFreshCache) { + return; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + + if (reset) { + threadHistory.clear(); + _threadHistoryCursor = null; + threadHistoryError = null; + notifyListeners(); + } + + isLoadingHistory = true; + threadHistoryError = null; + notifyListeners(); + + try { + final response = await _request('thread/list', { + 'limit': 25, + 'sortKey': 'updated_at', + if (!reset && _threadHistoryCursor != null) + 'cursor': _threadHistoryCursor, + }, _threadLoadTimeout); + final data = response?['data']; + final nextCursor = response?['nextCursor']; + final nextItems = []; + if (data is List) { + for (final item in data) { + final parsed = _parseThreadSummary(item); + if (parsed != null && parsed.id.isNotEmpty) { + nextItems.add(parsed); + } + } + } + + if (reset) { + threadHistory + ..clear() + ..addAll(nextItems); + } else { + final existingIds = threadHistory.map((item) => item.id).toSet(); + for (final item in nextItems) { + if (!existingIds.contains(item.id)) { + threadHistory.add(item); + } + } + } + _sortThreadHistory(); + + _threadHistoryCursor = nextCursor?.toString(); + _threadHistoryLoadedAt = DateTime.now(); + } catch (error) { + threadHistoryError = error.toString(); + } finally { + isLoadingHistory = false; + notifyListeners(); + } + } + + Future openFileBrowser({String? path}) async { + await loadDirectory(path ?? preferredFileBrowserRoot); + } + + Future loadModelOptions({bool force = false}) async { + if (isLoadingModels) { + return; + } + if (!force && + modelOptions.isNotEmpty && + _modelOptionsLoadedAt != null && + DateTime.now().difference(_modelOptionsLoadedAt!) < + const Duration(minutes: 5)) { + return; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + + isLoadingModels = true; + modelListError = null; + notifyListeners(); + + try { + final response = await _request('model/list', { + 'limit': 100, + }); + final data = response?['data']; + final nextOptions = []; + if (data is List) { + for (final item in data) { + if (item is! Map) { + continue; + } + nextOptions.add( + ModelOption( + id: item['id']?.toString() ?? '', + model: item['model']?.toString() ?? '', + displayName: item['displayName']?.toString() ?? '', + description: item['description']?.toString() ?? '', + isDefault: item['isDefault'] == true, + hidden: item['hidden'] == true, + ), + ); + } + } + nextOptions.sort((a, b) { + if (a.isDefault != b.isDefault) { + return a.isDefault ? -1 : 1; + } + return a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + }); + modelOptions + ..clear() + ..addAll(nextOptions.where((option) => !option.hidden)); + _modelOptionsLoadedAt = DateTime.now(); + } catch (error) { + modelListError = error.toString(); + } finally { + isLoadingModels = false; + notifyListeners(); + } + } + + Future loadDirectory(String path) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + fileBrowserError = 'File browser requires an absolute path.'; + notifyListeners(); + return; + } + + final cached = _directoryCache[normalizedPath]; + if (cached != null && + DateTime.now().difference(cached.loadedAt) < + const Duration(seconds: 20)) { + isLoadingFiles = false; + fileBrowserError = null; + fileBrowserPath = normalizedPath; + selectedFilePath = null; + selectedFileBytes = null; + selectedFileContent = null; + selectedFileIsHumanReadable = false; + selectedFileHighlightedLine = null; + fileBrowserEntries + ..clear() + ..addAll(cached.entries); + notifyListeners(); + return; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + + isLoadingFiles = true; + fileBrowserError = null; + fileBrowserPath = normalizedPath; + selectedFilePath = null; + selectedFileContent = null; + selectedFileBytes = null; + selectedFileIsHumanReadable = false; + selectedFileHighlightedLine = null; + notifyListeners(); + + try { + final nextEntries = await _readDirectoryEntries(normalizedPath); + nextEntries.sort((a, b) { + if (a.isDirectory != b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.fileName.toLowerCase().compareTo(b.fileName.toLowerCase()); + }); + fileBrowserEntries + ..clear() + ..addAll(nextEntries); + _directoryCache[normalizedPath] = _DirectoryCacheEntry( + entries: List.from(nextEntries), + loadedAt: DateTime.now(), + ); + } catch (error) { + fileBrowserError = error.toString(); + fileBrowserEntries.clear(); + } finally { + isLoadingFiles = false; + notifyListeners(); + } + } + + Future> _readDirectoryEntries(String path) async { + try { + final response = await _request('fs/readDirectory', { + 'path': path, + }, _directoryReadTimeout); + return _parseDirectoryEntries(response?['entries']); + } catch (_) { + return _readDirectoryEntriesViaCommand(path); + } + } + + List _parseDirectoryEntries(dynamic entriesRaw) { + final nextEntries = []; + if (entriesRaw is! List) { + return nextEntries; + } + for (final item in entriesRaw) { + if (item is! Map) { + continue; + } + nextEntries.add( + FileSystemEntry( + fileName: item['fileName']?.toString() ?? '', + isDirectory: item['isDirectory'] == true, + isFile: item['isFile'] == true, + ), + ); + } + return nextEntries; + } + + Future> _readDirectoryEntriesViaCommand( + String path, + ) async { + const script = ''' +import json +import os +import sys + +entries = [] +with os.scandir(sys.argv[1]) as it: + for entry in it: + try: + is_dir = entry.is_dir(follow_symlinks=False) + except OSError: + is_dir = False + try: + is_file = entry.is_file(follow_symlinks=False) + except OSError: + is_file = False + entries.append({ + "fileName": entry.name, + "isDirectory": is_dir, + "isFile": is_file, + }) + +print(json.dumps({"entries": entries})) +'''; + final response = await _request('command/exec', { + 'command': ['/usr/bin/env', 'python3', '-c', script, path], + 'sandboxPolicy': const {'type': 'readOnly'}, + }, _directoryReadTimeout); + final stdout = response?['stdout']?.toString() ?? ''; + if (stdout.trim().isEmpty) { + return []; + } + final decoded = jsonDecode(stdout) as Map; + return _parseDirectoryEntries(decoded['entries']); + } + + Future openFile(String path, {int? highlightedLine}) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + return; + } + + final cached = _filePreviewCache[normalizedPath]; + if (cached != null && + DateTime.now().difference(cached.loadedAt) < + const Duration(minutes: 2)) { + selectedFilePath = normalizedPath; + selectedFileBytes = cached.bytes; + selectedFileContent = cached.content; + selectedFileIsHumanReadable = cached.isHumanReadable; + selectedFileHighlightedLine = highlightedLine; + fileBrowserError = null; + notifyListeners(); + return; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + + isLoadingFilePreview = true; + filePreviewSaveError = null; + fileBrowserError = null; + selectedFilePath = normalizedPath; + selectedFileContent = null; + selectedFileBytes = null; + selectedFileIsHumanReadable = false; + selectedFileHighlightedLine = highlightedLine; + notifyListeners(); + + try { + final bytes = await readFileBytes(normalizedPath); + selectedFileBytes = bytes; + selectedFileIsHumanReadable = _isLikelyHumanReadableFile( + normalizedPath, + bytes, + ); + if (selectedFileIsHumanReadable) { + selectedFileContent = utf8.decode(bytes, allowMalformed: true); + } else { + selectedFileContent = null; + } + _filePreviewCache[normalizedPath] = _FilePreviewCacheEntry( + bytes: bytes, + content: selectedFileContent, + isHumanReadable: selectedFileIsHumanReadable, + loadedAt: DateTime.now(), + ); + } catch (error) { + fileBrowserError = error.toString(); + selectedFileBytes = null; + selectedFileContent = null; + selectedFileIsHumanReadable = false; + selectedFileHighlightedLine = null; + } finally { + isLoadingFilePreview = false; + notifyListeners(); + } + } + + Future saveOpenedFileContent(String content) async { + final selectedPath = selectedFilePath?.trim() ?? ''; + if (selectedPath.isEmpty) { + throw StateError('No file is open.'); + } + if (!selectedFileIsHumanReadable) { + throw StateError('This file cannot be edited as text.'); + } + if (!isConnected) { + await connect(); + if (!isConnected) { + throw StateError('Not connected.'); + } + } + + isSavingFilePreview = true; + filePreviewSaveError = null; + notifyListeners(); + try { + final bytes = Uint8List.fromList(utf8.encode(content)); + await _request('fs/writeFile', { + 'path': selectedPath, + 'dataBase64': base64Encode(bytes), + }, const Duration(minutes: 2)); + selectedFileContent = content; + selectedFileBytes = bytes; + _filePreviewCache[selectedPath] = _FilePreviewCacheEntry( + bytes: bytes, + content: content, + isHumanReadable: true, + loadedAt: DateTime.now(), + ); + } catch (error) { + filePreviewSaveError = error.toString(); + rethrow; + } finally { + isSavingFilePreview = false; + notifyListeners(); + } + } + + Future readFileBytes(String path) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + throw StateError('File browser requires an absolute path.'); + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + throw StateError('Not connected.'); + } + } + + final response = await _request('fs/readFile', { + 'path': normalizedPath, + }, const Duration(minutes: 2)); + final dataBase64 = response?['dataBase64']?.toString() ?? ''; + return Uint8List.fromList(base64Decode(dataBase64)); + } + + Future _downloadViaDirectHttpServer( + String path, { + required File targetFile, + ValueChanged? onProgress, + String? processId, + }) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + throw StateError('File browser requires an absolute path.'); + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + throw StateError('Not connected.'); + } + } + + onProgress?.call( + const FileDownloadStatus( + progress: 0, + receivedBytes: 0, + totalBytes: null, + eta: null, + ), + ); + final expectedBytes = await _readFileSizeViaCommand(normalizedPath); + final resolvedProcessId = + processId ?? 'download-${DateTime.now().microsecondsSinceEpoch}'; + final pending = _PendingDownload( + expectedBytes: expectedBytes, + onProgress: onProgress, + ); + _pendingDownloadsByProcessId[resolvedProcessId] = pending; + final pendingServer = _PendingTransferServer(); + _pendingTransferServersByProcessId[resolvedProcessId] = pendingServer; + final token = _randomTransferToken(); + + try { + final responseFuture = _request('command/exec', { + 'command': [ + '/usr/bin/env', + 'python3', + '-u', + '-c', + _directDownloadServerScript, + normalizedPath, + token, + ], + 'processId': resolvedProcessId, + 'streamStdoutStderr': true, + 'disableTimeout': true, + 'disableOutputCap': true, + 'sandboxPolicy': _buildCommandSandboxPolicy( + _settings.sandboxMode, + true, + preferredCommandCwd, + ), + }, const Duration(minutes: 30)); + + final endpoint = await pendingServer.waitForReady(); + final downloadUri = _buildDirectDownloadUri( + port: endpoint.port, + token: endpoint.token, + ); + await _downloadHttpFile( + uri: downloadUri, + targetFile: targetFile, + pending: pending, + ); + + final response = await responseFuture; + final exitCode = response?['exitCode'] as int?; + if (pending.isCancelled) { + throw const _DownloadCancelled(); + } + if (exitCode != null && exitCode != 0) { + if (pending.isCancelled) { + throw const _DownloadCancelled(); + } + final detail = pendingServer.stderr.trim(); + throw StateError( + detail.isEmpty + ? 'Download command failed with exit code $exitCode.' + : detail, + ); + } + pending.markProcessExited(); + await pending.waitForCompletion(); + onProgress?.call( + FileDownloadStatus( + progress: 1, + receivedBytes: pending.writtenBytes, + totalBytes: pending.expectedBytes ?? pending.writtenBytes, + eta: Duration.zero, + ), + ); + } finally { + _pendingDownloadsByProcessId.remove(resolvedProcessId); + _pendingTransferServersByProcessId.remove(resolvedProcessId); + } + } + + Future _downloadViaRelayHttp( + String path, { + required File targetFile, + required String processId, + ValueChanged? onProgress, + }) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + throw StateError('File browser requires an absolute path.'); + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + throw StateError('Not connected.'); + } + } + + final response = await _request('bridge/download/start', { + 'path': normalizedPath, + }, const Duration(minutes: 2)); + final url = response?['url']?.toString().trim() ?? ''; + if (url.isEmpty) { + throw StateError('Relay bridge did not provide a download URL.'); + } + final expectedBytes = response?['sizeBytes'] as int?; + onProgress?.call( + FileDownloadStatus( + progress: 0, + receivedBytes: 0, + totalBytes: expectedBytes, + eta: null, + ), + ); + final pending = _PendingDownload( + expectedBytes: expectedBytes, + onProgress: onProgress, + ); + _pendingDownloadsByProcessId[processId] = pending; + try { + await _downloadHttpFile( + uri: Uri.parse(url), + targetFile: targetFile, + pending: pending, + ); + onProgress?.call( + FileDownloadStatus( + progress: 1, + receivedBytes: pending.writtenBytes, + totalBytes: pending.expectedBytes ?? pending.writtenBytes, + eta: Duration.zero, + ), + ); + } finally { + _pendingDownloadsByProcessId.remove(processId); + } + } + + Future saveFileToDevice( + String path, { + String? preferredDirectory, + bool promptIfNeeded = true, + }) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + throw StateError('File browser requires an absolute path.'); + } + if (isFileDownloading(normalizedPath)) { + return null; + } + + final processId = 'download-${DateTime.now().microsecondsSinceEpoch}'; + _fileDownloadProcessIdByPath[normalizedPath] = processId; + _upsertDownloadRecord( + normalizedPath, + state: DownloadState.running, + status: const FileDownloadStatus( + progress: 0.04, + receivedBytes: 0, + totalBytes: null, + eta: null, + ), + ); + _setFileDownloadStatus( + normalizedPath, + const FileDownloadStatus( + progress: 0.04, + receivedBytes: 0, + totalBytes: null, + eta: null, + ), + ); + + File? targetFile; + try { + final fileName = normalizedPath + .split('/') + .where((part) => part.isNotEmpty) + .last; + final threadId = activeThreadId?.trim() ?? ''; + String? targetDirectory = preferredDirectory?.trim(); + if (targetDirectory == null || targetDirectory.isEmpty) { + targetDirectory = _preferredDownloadDirectoryForThread(threadId); + } + if (targetDirectory == null || targetDirectory.trim().isEmpty) { + if (!promptIfNeeded) { + throw StateError( + 'No download directory is configured for this thread.', + ); + } + targetDirectory = await FilePicker.platform.getDirectoryPath( + dialogTitle: 'Choose download location', + ); + if (targetDirectory != null && targetDirectory.trim().isNotEmpty) { + await _rememberDownloadDirectoryForThread(threadId, targetDirectory); + } + } + if (targetDirectory == null || targetDirectory.trim().isEmpty) { + return null; + } + final directory = Directory(targetDirectory); + await directory.create(recursive: true); + targetFile = await _nextAvailableFile(directory.path, fileName); + if (_settings.connectionMode == ConnectionMode.relay) { + await _downloadViaRelayHttp( + normalizedPath, + processId: processId, + targetFile: targetFile, + onProgress: (FileDownloadStatus status) { + _setFileDownloadStatus(normalizedPath, status); + }, + ); + } else { + await _downloadViaDirectHttpServer( + normalizedPath, + processId: processId, + targetFile: targetFile, + onProgress: (FileDownloadStatus status) { + _setFileDownloadStatus(normalizedPath, status); + }, + ); + } + final currentStatus = fileDownloadStatus(normalizedPath); + _setFileDownloadStatus( + normalizedPath, + FileDownloadStatus( + progress: 1, + receivedBytes: + currentStatus?.totalBytes ?? currentStatus?.receivedBytes ?? 0, + totalBytes: currentStatus?.totalBytes ?? currentStatus?.receivedBytes, + eta: Duration.zero, + ), + ); + _upsertDownloadRecord( + normalizedPath, + state: DownloadState.completed, + targetPath: targetFile.path, + status: fileDownloadStatus(normalizedPath), + ); + return targetFile.path; + } on _DownloadCancelled { + if (targetFile != null && await targetFile.exists()) { + await targetFile.delete(); + } + _upsertDownloadRecord( + normalizedPath, + state: DownloadState.cancelled, + targetPath: targetFile?.path, + status: fileDownloadStatus(normalizedPath), + ); + notifyListeners(); + return null; + } catch (error) { + if (targetFile != null && await targetFile.exists()) { + await targetFile.delete(); + } + _upsertDownloadRecord( + normalizedPath, + state: DownloadState.failed, + targetPath: targetFile?.path, + status: fileDownloadStatus(normalizedPath), + error: error.toString(), + ); + notifyListeners(); + rethrow; + } finally { + _fileDownloadProcessIdByPath.remove(normalizedPath); + await Future.delayed(const Duration(milliseconds: 220)); + _clearFileDownloadProgress(normalizedPath); + } + } + + Future cancelFileDownload(String path) async { + final normalizedPath = _normalizeAbsolutePath(path); + final processId = _fileDownloadProcessIdByPath[normalizedPath]; + if (processId == null) { + return; + } + final pending = _pendingDownloadsByProcessId[processId]; + pending?.cancel(); + try { + await _request('command/exec/terminate', { + 'processId': processId, + }); + } catch (_) { + // Ignore termination failures; local cancellation state is still enough. + } + } + + void clearFinishedDownloads() { + downloadRecords.removeWhere((item) => item.state != DownloadState.running); + notifyListeners(); + } + + Future _syncAutomationWatches() async { + final inFlight = _automationWatchSyncCompleter; + if (inFlight != null) { + _automationWatchSyncQueued = true; + return inFlight.future; + } + final completer = Completer(); + _automationWatchSyncCompleter = completer; + try { + do { + _automationWatchSyncQueued = false; + await _performAutomationWatchSync(); + } while (_automationWatchSyncQueued); + completer.complete(); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + rethrow; + } finally { + _automationWatchSyncCompleter = null; + } + } + + Future _performAutomationWatchSync() async { + if (!isConnected) { + return; + } + + final desired = {}; + for (final automation in automations) { + if (!automation.enabled) { + continue; + } + if (!isAutomationVisibleInCurrentThread(automation)) { + continue; + } + final trigger = automation.triggerNode; + if (trigger == null) { + continue; + } + if (trigger.kind == AutomationNodeKind.turnCompleted) { + continue; + } + final normalizedPath = _normalizeAbsolutePath(trigger.path); + if (normalizedPath.isEmpty) { + continue; + } + desired[automation.id] = trigger.copyWith(path: normalizedPath); + } + + final staleIds = _activeAutomationWatches.keys + .where((automationId) { + final active = _activeAutomationWatches[automationId]; + final desiredNode = desired[automationId]; + return active == null || + desiredNode == null || + active.path != desiredNode.path || + active.kind != desiredNode.kind; + }) + .toList(growable: false); + for (final automationId in staleIds) { + final active = _activeAutomationWatches.remove(automationId); + if (active == null) { + continue; + } + _automationDebounceTimers.remove(automationId)?.cancel(); + _debouncedAutomationChangedPaths.remove(automationId); + } + + final desiredPaths = desired.values.map((item) => item.path).toSet(); + final stalePaths = _registeredAutomationWatches.keys + .where((path) => !desiredPaths.contains(path)) + .toList(growable: false); + for (final path in stalePaths) { + final registered = _registeredAutomationWatches.remove(path); + if (registered == null) { + continue; + } + try { + await _request('fs/unwatch', { + 'watchId': registered.watchId, + }); + } catch (_) { + // Ignore best-effort cleanup failures during resync. + } + } + + for (final entry in desired.entries) { + final active = _activeAutomationWatches[entry.key]; + if (active != null && + active.path == entry.value.path && + active.kind == entry.value.kind) { + continue; + } + try { + final registered = await _ensureRegisteredAutomationWatch( + entry.value.path, + ); + if (registered == null) { + continue; + } + _activeAutomationWatches[entry.key] = _ActiveAutomationWatch( + automationId: entry.key, + watchId: registered.watchId, + path: registered.path, + kind: entry.value.kind, + ); + } catch (error) { + _addSystemEntry( + 'Automation watch failed for ${_automationName(entry.key)}: $error', + ); + } + } + notifyListeners(); + } + + Future<_RegisteredAutomationWatch?> _ensureRegisteredAutomationWatch( + String path, + ) async { + final existing = _registeredAutomationWatches[path]; + if (existing != null) { + return existing; + } + final response = await _request('fs/watch', { + 'path': path, + }); + final watchId = response?['watchId']?.toString() ?? ''; + if (watchId.isEmpty) { + return null; + } + final registered = _RegisteredAutomationWatch( + watchId: watchId, + path: response?['path']?.toString() ?? path, + ); + _registeredAutomationWatches[path] = registered; + return registered; + } + + Future _handleAutomationFsChanged( + String watchId, + List changedPaths, + ) async { + final activeWatches = _activeAutomationWatches.values + .where((watch) => watch.watchId == watchId) + .toList(growable: false); + if (activeWatches.isEmpty) { + return; + } + for (final activeWatch in activeWatches) { + AutomationDefinition? automation; + for (final item in automations) { + if (item.id == activeWatch.automationId) { + automation = item; + break; + } + } + if (automation == null || !automation.enabled) { + continue; + } + final relevantPaths = _matchingAutomationChangedPaths( + activeWatch, + changedPaths, + ); + if (relevantPaths.isEmpty) { + continue; + } + final automationId = automation.id; + final pendingPaths = { + ...?_debouncedAutomationChangedPaths[automationId], + ...relevantPaths, + }.toList(growable: false); + _debouncedAutomationChangedPaths[automationId] = pendingPaths; + _automationDebounceTimers.remove(automationId)?.cancel(); + _automationDebounceTimers[automationId] = Timer( + _automationFsQuietPeriod, + () { + _automationDebounceTimers.remove(automationId); + final stabilizedPaths = + _debouncedAutomationChangedPaths.remove(automationId) ?? + const []; + if (stabilizedPaths.isEmpty) { + return; + } + unawaited( + _triggerAutomationAfterQuietPeriod( + automation!, + activeWatch, + stabilizedPaths, + ), + ); + }, + ); + } + } + + Future _triggerAutomationAfterQuietPeriod( + AutomationDefinition automation, + _ActiveAutomationWatch activeWatch, + List relevantPaths, + ) async { + if (_runningAutomationIds.contains(automation.id)) { + _queuedAutomationChangedPaths[automation.id] = relevantPaths; + notifyListeners(); + return; + } + _runningAutomationIds.add(automation.id); + notifyListeners(); + unawaited( + _runAutomation(automation, activeWatch, relevantPaths).whenComplete( + () async { + _runningAutomationIds.remove(automation.id); + notifyListeners(); + final queued = _queuedAutomationChangedPaths.remove(automation.id); + if (queued != null && queued.isNotEmpty) { + await _handleAutomationFsChanged(activeWatch.watchId, queued); + } + }, + ), + ); + } + + List _matchingAutomationChangedPaths( + _ActiveAutomationWatch activeWatch, + List changedPaths, + ) { + final normalized = changedPaths + .map(_normalizeAbsolutePath) + .where((item) => item.isNotEmpty) + .toList(growable: false); + if (activeWatch.kind == AutomationNodeKind.watchFileChanged) { + return normalized.where((item) => item == activeWatch.path).toList(); + } + return normalized.where((item) { + return item == activeWatch.path || + item.startsWith('${activeWatch.path}/'); + }).toList(); + } + + Future _runAutomation( + AutomationDefinition automation, + _ActiveAutomationWatch activeWatch, + List changedPaths, + ) async { + final context = _AutomationExecutionContext( + changedPaths: changedPaths, + watchedPath: activeWatch.path, + triggerKind: activeWatch.kind, + ); + _addSystemEntry('Automation "${automation.name}" triggered.'); + try { + for (final node in automation.actionNodes) { + switch (node.kind) { + case AutomationNodeKind.turnCompleted: + break; + case AutomationNodeKind.didPathChangeSinceLastRun: + final comparisonPath = _resolveAutomationComparisonPath( + node, + context, + activeWatch, + ); + if (comparisonPath.isEmpty) { + throw StateError( + 'No file or folder path was configured to compare.', + ); + } + final currentSnapshot = await _captureAutomationSnapshot( + comparisonPath, + ); + final previousSnapshot = _automationSnapshotFor( + automation.id, + comparisonPath, + ); + final changed = + previousSnapshot == null || previousSnapshot != currentSnapshot; + await _storeAutomationSnapshot( + automation.id, + comparisonPath, + currentSnapshot, + ); + context.recordNodeOutput(node.id, { + 'changed': changed ? 'true' : 'false', + 'path': comparisonPath, + 'snapshot': currentSnapshot, + }); + case AutomationNodeKind.ifElse: + final outcome = _evaluateAutomationBranch(node, context); + context.recordNodeOutput(node.id, { + 'condition': _resolveAutomationConditionValue(node, context), + 'outcome': outcome.name, + }); + if (outcome == AutomationBranchOutcome.quitFlow) { + _addSystemEntry( + 'Automation "${automation.name}" stopped by ${node.kind.title}.', + ); + return; + } + case AutomationNodeKind.quit: + context.recordNodeOutput(node.id, { + 'outcome': AutomationBranchOutcome.quitFlow.name, + }); + _addSystemEntry( + 'Automation "${automation.name}" stopped by ${node.kind.title}.', + ); + return; + case AutomationNodeKind.downloadChangedFile: + final sourcePath = _resolveAutomationDownloadSourcePath( + node, + context, + activeWatch, + ); + if (sourcePath == null) { + throw StateError('No changed file was available to download.'); + } + final target = await saveFileToDevice( + sourcePath, + preferredDirectory: + _resolveAutomationTemplate( + node.directory, + context, + ).trim().isEmpty + ? null + : _resolveAutomationTemplate(node.directory, context).trim(), + promptIfNeeded: false, + ); + if (target == null || target.trim().isEmpty) { + throw StateError('Download was cancelled.'); + } + context.lastDownloadedPath = target; + context.recordNodeOutput(node.id, { + 'sourcePath': sourcePath, + 'downloadedPath': target, + }); + case AutomationNodeKind.installDownloadedApk: + final installPath = _resolveAutomationInstallPath(node, context); + if (installPath.isEmpty) { + throw StateError('No downloaded file was available to install.'); + } + if (!installPath.toLowerCase().endsWith('.apk')) { + throw StateError('The downloaded file is not an APK.'); + } + final opened = await _openPath(installPath); + if (!opened) { + throw StateError('Unable to open the downloaded APK.'); + } + context.recordNodeOutput(node.id, { + 'installedPath': installPath, + }); + case AutomationNodeKind.sendMessageToCurrentThread: + final messageText = _resolveAutomationTemplate( + node.commandText, + context, + ).trim(); + if (messageText.isEmpty) { + throw StateError('Automation message is empty.'); + } + await _sendAutomationMessage(messageText); + context.recordNodeOutput(node.id, { + 'messageText': messageText, + }); + case AutomationNodeKind.runCommand: + final commandText = _resolveAutomationTemplate( + node.commandText, + context, + ).trim(); + if (commandText.isEmpty) { + throw StateError('Automation command is empty.'); + } + final resolvedCwd = _resolveAutomationTemplate( + node.cwd, + context, + ).trim(); + final cwd = resolvedCwd.isNotEmpty + ? resolvedCwd + : _defaultAutomationCommandCwd(context); + final session = await _runAutomationCommand(commandText, cwd: cwd); + context.recordNodeOutput(node.id, { + 'commandText': commandText, + 'cwd': cwd, + 'stdout': session?.stdout ?? '', + 'stderr': session?.stderr ?? '', + 'processId': session?.processId ?? '', + }); + case AutomationNodeKind.watchFileChanged: + case AutomationNodeKind.watchDirectoryChanged: + // Trigger nodes are handled by fs/watch registration. + break; + } + } + _addSystemEntry('Automation "${automation.name}" completed.'); + } catch (error) { + _addSystemEntry('Automation "${automation.name}" failed: $error'); + } + } + + String? _resolveAutomationChangedFile( + _AutomationExecutionContext context, + _ActiveAutomationWatch activeWatch, + ) { + if (activeWatch.kind == AutomationNodeKind.watchFileChanged) { + return context.changedPaths.isEmpty + ? activeWatch.path + : context.changedPaths.first; + } + for (final path in context.changedPaths) { + if (!_looksLikeDirectoryPath(path)) { + return path; + } + } + return null; + } + + String? _resolveAutomationDownloadSourcePath( + AutomationNode node, + _AutomationExecutionContext context, + _ActiveAutomationWatch activeWatch, + ) { + final configuredPath = _resolveAutomationTemplate( + node.path, + context, + ).trim(); + if (configuredPath.isNotEmpty) { + return configuredPath; + } + return _resolveAutomationChangedFile(context, activeWatch); + } + + String _resolveAutomationComparisonPath( + AutomationNode node, + _AutomationExecutionContext context, + _ActiveAutomationWatch activeWatch, + ) { + final configuredPath = _resolveAutomationTemplate( + node.path, + context, + ).trim(); + if (configuredPath.isNotEmpty) { + return configuredPath; + } + if (context.triggerKind == AutomationNodeKind.watchDirectoryChanged || + context.triggerKind == AutomationNodeKind.watchFileChanged) { + return activeWatch.path; + } + return context.watchedPath; + } + + String _resolveAutomationInstallPath( + AutomationNode node, + _AutomationExecutionContext context, + ) { + final configuredPath = _resolveAutomationTemplate( + node.path, + context, + ).trim(); + if (configuredPath.isNotEmpty) { + return configuredPath; + } + final previousDownloadedPath = + context.valueForToken('previous.downloadedPath')?.trim() ?? ''; + if (previousDownloadedPath.isNotEmpty) { + return previousDownloadedPath; + } + return context.lastDownloadedPath?.trim() ?? ''; + } + + String _resolveAutomationConditionValue( + AutomationNode node, + _AutomationExecutionContext context, + ) { + final template = node.conditionToken.trim().isEmpty + ? '{{previous.changed}}' + : node.conditionToken.trim(); + return _resolveAutomationTemplate(template, context).trim(); + } + + AutomationBranchOutcome _evaluateAutomationBranch( + AutomationNode node, + _AutomationExecutionContext context, + ) { + final value = _resolveAutomationConditionValue(node, context).toLowerCase(); + final isTruthy = + value == 'true' || + value == '1' || + value == 'yes' || + value == 'y' || + value == 'continue'; + return isTruthy ? node.whenTrue : node.whenFalse; + } + + bool _looksLikeDirectoryPath(String path) { + final parts = path.split('/').where((part) => part.isNotEmpty).toList(); + final name = parts.isEmpty ? '' : parts.last; + return name.isEmpty || !name.contains('.'); + } + + String _defaultAutomationCommandCwd(_AutomationExecutionContext context) { + if (context.triggerKind == AutomationNodeKind.turnCompleted) { + return preferredCommandCwd; + } + if (context.triggerKind == AutomationNodeKind.watchDirectoryChanged) { + return context.watchedPath; + } + final segments = context.watchedPath + .split('/') + .where((part) => part.isNotEmpty) + .toList(); + if (segments.isEmpty) { + return preferredCommandCwd; + } + final parent = '/${segments.take(segments.length - 1).join('/')}'; + return parent == '/' ? parent : _normalizeAbsolutePath(parent); + } + + Future _runAutomationCommand( + String commandText, { + required String cwd, + }) async { + return _startCommandExecutionInternal( + commandText: commandText, + cwd: cwd, + sandboxMode: _settings.sandboxMode, + allowNetwork: _settings.allowNetwork, + mode: CommandSessionMode.buffered, + timeoutMs: 30 * 60 * 1000, + disableTimeout: true, + outputBytesCap: 32768, + disableOutputCap: true, + rememberRecent: false, + awaitCompletion: true, + ); + } + + Future _sendAutomationMessage(String messageText) async { + if (!isConnected) { + await connect(); + if (!isConnected) { + throw StateError('Unable to connect to the app-server.'); + } + } + if (activeThreadId == null || activeThreadId!.trim().isEmpty) { + throw StateError('No active thread is available.'); + } + if (hasActiveTurn) { + _enqueuePendingPrompt(messageText, PendingPromptMode.queued); + return; + } + await _startTurn(messageText, const []); + } + + String _resolveAutomationTemplate( + String value, + _AutomationExecutionContext context, + ) { + if (value.isEmpty) { + return value; + } + return value.replaceAllMapped( + RegExp(r'\{\{\s*([^}]+?)\s*\}\}'), + (match) => context.valueForToken(match.group(1)?.trim() ?? '') ?? '', + ); + } + + String _automationName(String automationId) { + for (final automation in automations) { + if (automation.id == automationId) { + return automation.name; + } + } + return automationId; + } + + String? _automationSnapshotFor(String automationId, String path) { + return _settings.automationSnapshots[automationId]?[path]; + } + + Future _storeAutomationSnapshot( + String automationId, + String path, + String snapshot, + ) async { + final normalizedAutomationId = automationId.trim(); + final normalizedPath = path.trim(); + if (normalizedAutomationId.isEmpty || + normalizedPath.isEmpty || + snapshot.trim().isEmpty) { + return; + } + final nextSnapshots = >{}; + for (final entry in _settings.automationSnapshots.entries) { + nextSnapshots[entry.key] = Map.from(entry.value); + } + final automationSnapshots = + nextSnapshots[normalizedAutomationId] ?? {}; + automationSnapshots[normalizedPath] = snapshot; + nextSnapshots[normalizedAutomationId] = automationSnapshots; + _settings = _settings.copyWith(automationSnapshots: nextSnapshots); + await _settingsStore.save(_settings); + } + + Future _captureAutomationSnapshot(String path) async { + final normalizedPath = _normalizeAbsolutePath(path); + if (normalizedPath.isEmpty) { + throw StateError('Automation comparison path must be absolute.'); + } + final metadata = await _request('fs/getMetadata', { + 'path': normalizedPath, + }); + if (metadata == null) { + throw StateError('Unable to read metadata for $normalizedPath.'); + } + final isDirectory = metadata['isDirectory'] == true; + final isFile = metadata['isFile'] == true; + final modifiedAtMs = metadata['modifiedAtMs']?.toString() ?? '0'; + final createdAtMs = metadata['createdAtMs']?.toString() ?? '0'; + if (isFile) { + return 'file|$normalizedPath|$createdAtMs|$modifiedAtMs'; + } + if (!isDirectory) { + return 'missing|$normalizedPath'; + } + final entries = await _readDirectoryEntries(normalizedPath); + final signatures = []; + for (final entry in entries) { + final fileName = entry.fileName; + if (fileName.trim().isEmpty) { + continue; + } + final childPath = normalizedPath == '/' + ? '/$fileName' + : '$normalizedPath/$fileName'; + final childMetadata = await _request('fs/getMetadata', { + 'path': childPath, + }); + final childModifiedAtMs = + childMetadata?['modifiedAtMs']?.toString() ?? '0'; + final childType = childMetadata?['isDirectory'] == true ? 'dir' : 'file'; + signatures.add('$fileName|$childType|$childModifiedAtMs'); + } + signatures.sort(); + return 'dir|$normalizedPath|$modifiedAtMs|${signatures.join(';')}'; + } + + Future _handleAutomationTurnCompleted(String turnId) async { + for (final automation in automations) { + if (!automation.enabled) { + continue; + } + if (!isAutomationVisibleInCurrentThread(automation)) { + continue; + } + final trigger = automation.triggerNode; + if (trigger?.kind != AutomationNodeKind.turnCompleted) { + continue; + } + if (_runningAutomationIds.contains(automation.id)) { + continue; + } + _runningAutomationIds.add(automation.id); + notifyListeners(); + final syntheticTrigger = _ActiveAutomationWatch( + automationId: automation.id, + watchId: 'turn-completed:$turnId', + path: preferredCommandCwd, + kind: AutomationNodeKind.turnCompleted, + ); + unawaited( + _runAutomation(automation, syntheticTrigger, [ + turnId, + ]).whenComplete(() { + _runningAutomationIds.remove(automation.id); + notifyListeners(); + }), + ); + } + } + + Uri _buildDirectDownloadUri({required int port, required String token}) { + final serverUri = Uri.parse(_settings.serverUrl); + final scheme = serverUri.scheme == 'wss' ? 'https' : 'http'; + final host = serverUri.host; + if (host.isEmpty) { + throw StateError('Cannot determine a download host from the server URL.'); + } + return Uri(scheme: scheme, host: host, port: port, path: '/$token'); + } + + Future _downloadHttpFile({ + required Uri uri, + required File targetFile, + required _PendingDownload pending, + }) async { + final client = _httpClientFactory(); + IOSink? sink; + try { + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw StateError('Download server returned ${response.statusCode}.'); + } + sink = targetFile.openWrite(mode: FileMode.writeOnly); + await for (final chunk in response) { + if (pending.isCancelled) { + throw const _DownloadCancelled(); + } + sink.add(chunk); + pending.addBytes(chunk.length); + } + await sink.flush(); + await sink.close(); + } finally { + client.close(force: true); + if (sink != null) { + try { + await sink.close(); + } catch (_) {} + } + } + } + + String _randomTransferToken() { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final random = Random.secure(); + final buffer = StringBuffer(); + for (var index = 0; index < 24; index += 1) { + buffer.write(chars[random.nextInt(chars.length)]); + } + return buffer.toString(); + } + + static const String _directDownloadServerScript = r''' +import http.server +import json +import os +import socketserver +import sys +import time + +file_path = sys.argv[1] +token = sys.argv[2] + +class OneShotHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path != f"/{token}": + self.send_response(404) + self.end_headers() + return + stat = os.stat(file_path) + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(stat.st_size)) + self.send_header( + "Content-Disposition", + f'attachment; filename="{os.path.basename(file_path)}"', + ) + self.end_headers() + with open(file_path, "rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + self.server.served = True + + def log_message(self, format, *args): + return + +class OneShotServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + allow_reuse_address = True + +server = OneShotServer(("0.0.0.0", 0), OneShotHandler) +server.served = False +server.timeout = 1 +print(json.dumps({ + "event": "ready", + "port": server.server_address[1], + "token": token, +}), flush=True) + +deadline = time.time() + 300 +while not server.served and time.time() < deadline: + server.handle_request() +'''; + + Future _readFileSizeViaCommand(String path) async { + try { + final response = await _request('command/exec', { + 'command': ['/bin/bash', '-lc', r'wc -c < "$1"', 'bash', path], + 'sandboxPolicy': _buildCommandSandboxPolicy( + _settings.sandboxMode, + false, + preferredCommandCwd, + ), + }, const Duration(seconds: 30)); + final stdout = response?['stdout']?.toString().trim() ?? ''; + return int.tryParse(stdout); + } catch (_) { + return null; + } + } + + String _joinFilePath(String directory, String fileName) { + final normalizedDirectory = directory.endsWith(Platform.pathSeparator) + ? directory.substring(0, directory.length - 1) + : directory; + return '$normalizedDirectory${Platform.pathSeparator}$fileName'; + } + + Future _nextAvailableFile(String directory, String fileName) async { + final dotIndex = fileName.lastIndexOf('.'); + final hasExtension = dotIndex > 0; + final baseName = hasExtension ? fileName.substring(0, dotIndex) : fileName; + final extension = hasExtension ? fileName.substring(dotIndex) : ''; + var candidate = File(_joinFilePath(directory, fileName)); + var suffix = 1; + while (await candidate.exists()) { + candidate = File( + _joinFilePath(directory, '$baseName ($suffix)$extension'), + ); + suffix += 1; + } + return candidate; + } + + String? resolveFileReferencePath(String rawPath) { + final trimmed = rawPath.trim(); + if (trimmed.isEmpty) { + return null; + } + final absolute = _normalizeAbsolutePath(trimmed); + if (absolute.isNotEmpty) { + return absolute; + } + final base = preferredCommandCwd.trim(); + if (base.isEmpty) { + return null; + } + return _normalizeAbsolutePath(_joinFilePath(base, trimmed)); + } + + void _setFileDownloadStatus(String path, FileDownloadStatus value) { + _fileDownloadStatusByPath[path] = value; + _upsertDownloadRecord(path, status: value, state: DownloadState.running); + notifyListeners(); + } + + void _clearFileDownloadProgress(String path) { + if (_fileDownloadStatusByPath.remove(path) != null) { + notifyListeners(); + } + } + + void _upsertDownloadRecord( + String path, { + required DownloadState state, + FileDownloadStatus? status, + String? targetPath, + String? error, + }) { + final normalizedPath = _normalizeAbsolutePath(path); + final parts = normalizedPath + .split('/') + .where((part) => part.isNotEmpty) + .toList(); + final fileName = parts.isEmpty ? normalizedPath : parts.last; + final index = downloadRecords.indexWhere( + (item) => item.sourcePath == normalizedPath, + ); + final previous = index >= 0 ? downloadRecords[index] : null; + final next = DownloadRecord( + sourcePath: normalizedPath, + fileName: fileName, + targetPath: targetPath ?? previous?.targetPath, + state: state, + status: status ?? previous?.status, + error: error ?? previous?.error, + startedAt: previous?.startedAt ?? DateTime.now(), + finishedAt: state == DownloadState.running ? null : DateTime.now(), + ); + if (index >= 0) { + downloadRecords[index] = next; + } else { + downloadRecords.insert(0, next); + } + } + + Future navigateToParentDirectory() async { + final current = fileBrowserPath.trim(); + if (current.isEmpty || current == '/') { + return; + } + final slashIndex = current.lastIndexOf('/'); + final parent = slashIndex <= 0 ? '/' : current.substring(0, slashIndex); + await loadDirectory(parent); + } + + String joinFileBrowserPath(String childName) { + final base = fileBrowserPath.trim(); + if (base.isEmpty || base == '/') { + return '/$childName'; + } + return '$base/$childName'; + } + + Future resumeThreadFromHistory(String threadId) async { + if (threadId.isEmpty) { + return; + } + if (activeThreadId == threadId) { + return; + } + if (isOpeningThread) { + return; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + + openingThreadId = threadId; + notifyListeners(); + + try { + final readResponse = await _request('thread/read', { + 'threadId': threadId, + 'includeTurns': true, + }, _threadLoadTimeout); + final thread = readResponse?['thread']; + if (thread is Map) { + _hydrateEntriesFromThread(thread); + activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; + activeThreadName = thread['name']?.toString() ?? activeThreadName; + } + + final resumeResponse = await _request('thread/resume', { + 'threadId': threadId, + }, _threadLoadTimeout); + final resumed = resumeResponse?['thread']; + final resumedTurn = resumeResponse?['turn']; + if (resumed is Map) { + activeThreadId = resumed['id']?.toString() ?? threadId; + _subscribedThreadId = activeThreadId; + activeThreadCwd = resumed['cwd']?.toString() ?? activeThreadCwd; + activeThreadName = resumed['name']?.toString() ?? activeThreadName; + } else { + activeThreadId = threadId; + _subscribedThreadId = threadId; + } + if (resumedTurn is Map) { + _hydrateResumeTurn(resumedTurn); + } else { + activeTurnId = _activeTurnIdsByThread[threadId]; + statusMessage = activeTurnId == null ? 'Ready' : 'Turn running'; + } + + await saveSettings( + _settings.copyWith(resumeThreadId: activeThreadId ?? threadId), + ); + _resyncAutomationWatchesForCurrentThread(); + _addSystemEntry( + 'Resumed thread ${activeThreadName?.trim().isNotEmpty == true ? activeThreadName : activeThreadId ?? threadId}.', + ); + } catch (error) { + _addSystemEntry('Thread resume failed: $error'); + } finally { + openingThreadId = null; + notifyListeners(); + } + } + + Future renameThread(String threadId, String name) async { + final trimmed = name.trim(); + if (threadId.trim().isEmpty || trimmed.isEmpty) { + return; + } + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + await _request('thread/name/set', { + 'threadId': threadId, + 'name': trimmed, + }); + _updateThreadSummaryName(threadId, trimmed); + if (activeThreadId == threadId) { + activeThreadName = trimmed; + } + notifyListeners(); + } + + Future sendPrompt( + String prompt, { + List attachments = const [], + }) async { + final trimmed = prompt.trim(); + final normalizedAttachments = List.from(attachments); + if (trimmed.isEmpty && normalizedAttachments.isEmpty) { + return; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return; + } + } + + await _ensureThread(); + if (activeThreadId == null) { + _addSystemEntry('Unable to start a thread.'); + return; + } + + if (hasActiveTurn) { + _enqueuePendingPrompt( + trimmed, + PendingPromptMode.queued, + attachments: normalizedAttachments, + ); + return; + } + + await _startTurn(trimmed, normalizedAttachments); + } + + Future steerPrompt( + String prompt, { + List attachments = const [], + }) async { + final trimmed = prompt.trim(); + final normalizedAttachments = List.from(attachments); + if (trimmed.isEmpty && normalizedAttachments.isEmpty) { + return false; + } + + if (!isConnected) { + await connect(); + if (!isConnected) { + return false; + } + } + + await _ensureThread(); + if (activeThreadId == null || !hasActiveTurn || isSteering) { + return false; + } + _enqueuePendingPrompt( + trimmed, + PendingPromptMode.steer, + attachments: normalizedAttachments, + ); + return true; + } + + Future interruptTurn() async { + if (activeThreadId == null || activeTurnId == null) { + return; + } + + try { + await _request('turn/interrupt', { + 'threadId': activeThreadId, + 'turnId': activeTurnId, + }); + _addSystemEntry('Interrupt requested for $activeTurnId.'); + } catch (error) { + _addSystemEntry('Interrupt failed: $error'); + } + notifyListeners(); + } + + Future _startTurn( + String prompt, + List attachments, + ) async { + var optimisticEntryKey = ''; + var uploadedImagePaths = []; + try { + final preparedInput = await _buildUserInput(prompt, attachments); + final input = preparedInput.input; + uploadedImagePaths = preparedInput.uploadedImagePaths; + optimisticEntryKey = _addOptimisticUserEntry(prompt, attachments); + final containsImageAttachment = attachments.any((item) => item.isImage); + final response = await _request( + 'turn/start', + { + 'threadId': activeThreadId, + 'input': input, + if (activeThreadCwd.trim().isNotEmpty) 'cwd': activeThreadCwd.trim(), + if (_settings.model.trim().isNotEmpty) + 'model': _settings.model.trim(), + if (_settings.reasoningEffort.trim().isNotEmpty) + 'effort': _settings.reasoningEffort.trim(), + 'approvalPolicy': normalizeApprovalPolicy(_settings.approvalPolicy), + 'sandboxPolicy': _buildSandboxPolicy(), + 'personality': 'pragmatic', + }, + containsImageAttachment + ? const Duration(minutes: 5) + : const Duration(seconds: 20), + ); + + final turn = response?['turn']; + if (turn is Map) { + activeTurnId = turn['id'] as String?; + final threadId = activeThreadId?.trim() ?? ''; + if (threadId.isNotEmpty && activeTurnId != null) { + _activeTurnIdsByThread[threadId] = activeTurnId!; + } + _trackUploadedImagePaths(activeTurnId, uploadedImagePaths); + uploadedImagePaths = []; + statusMessage = 'Turn running'; + notifyListeners(); + } + } catch (error) { + await _cleanupUploadedImagePaths(uploadedImagePaths); + if (optimisticEntryKey.isNotEmpty) { + _discardOptimisticUserEntry(optimisticEntryKey); + } + _addSystemEntry('Prompt failed: $error'); + notifyListeners(); + } + } + + Future _steerPrompt( + String prompt, + List attachments, + ) async { + if (activeThreadId == null || activeTurnId == null) { + return false; + } + final containsImageAttachment = attachments.any((item) => item.isImage); + var uploadedImagePaths = []; + + try { + isSteering = true; + notifyListeners(); + final preparedInput = await _buildUserInput(prompt, attachments); + uploadedImagePaths = preparedInput.uploadedImagePaths; + final response = await _request( + 'turn/steer', + { + 'threadId': activeThreadId, + 'expectedTurnId': activeTurnId, + 'input': preparedInput.input, + }, + containsImageAttachment + ? const Duration(minutes: 5) + : const Duration(seconds: 20), + ); + final turnId = response?['turnId']?.toString(); + if (turnId != null && turnId.isNotEmpty) { + activeTurnId = turnId; + final threadId = activeThreadId?.trim() ?? ''; + if (threadId.isNotEmpty) { + _activeTurnIdsByThread[threadId] = turnId; + } + _trackUploadedImagePaths(turnId, uploadedImagePaths); + uploadedImagePaths = []; + } + _addSystemEntry('Sent as steer input.'); + return true; + } catch (_) { + await _cleanupUploadedImagePaths(uploadedImagePaths); + return false; + } finally { + isSteering = false; + notifyListeners(); + } + } + + void _enqueuePendingPrompt( + String prompt, + PendingPromptMode mode, { + List attachments = const [], + }) { + pendingPrompts.add( + PendingPrompt( + id: 'pending-${DateTime.now().microsecondsSinceEpoch}', + text: prompt, + mode: mode, + attachments: List.from(attachments), + ), + ); + notifyListeners(); + unawaited(_processPendingPrompts()); + } + + void cancelPendingPrompt(String id) { + pendingPrompts.removeWhere((item) => item.id == id); + notifyListeners(); + } + + PendingPrompt? takePendingPromptForEditing(String id) { + final index = pendingPrompts.indexWhere((item) => item.id == id); + if (index < 0) { + return null; + } + final value = pendingPrompts.removeAt(index); + notifyListeners(); + return value; + } + + bool promotePendingPromptToSteer(String id) { + final index = pendingPrompts.indexWhere((item) => item.id == id); + if (index < 0) { + return false; + } + final current = pendingPrompts[index]; + if (current.mode == PendingPromptMode.steer) { + return false; + } + pendingPrompts[index] = current.copyWith(mode: PendingPromptMode.steer); + notifyListeners(); + unawaited(_processPendingPrompts()); + return true; + } + + Future _processPendingPrompts() async { + if (pendingPrompts.isEmpty) { + return; + } + final nextPrompt = pendingPrompts.first; + if (nextPrompt.mode == PendingPromptMode.steer) { + if (!hasActiveTurn || isSteering) { + return; + } + final accepted = await _steerPrompt( + nextPrompt.text, + nextPrompt.attachments, + ); + if (accepted) { + pendingPrompts.removeWhere((item) => item.id == nextPrompt.id); + notifyListeners(); + } + return; + } + if (hasActiveTurn) { + return; + } + await _startTurn(nextPrompt.text, nextPrompt.attachments); + pendingPrompts.removeWhere((item) => item.id == nextPrompt.id); + notifyListeners(); + } + + Future<_PreparedUserInput> _buildUserInput( + String prompt, + List attachments, + ) async { + final input = >[]; + final uploadedImagePaths = []; + final fileAttachments = attachments + .where((item) => item.isTextFile) + .toList(growable: false); + final imageAttachments = attachments + .where((item) => item.isImage) + .toList(growable: false); + final text = _composeTextInput(prompt, fileAttachments); + if (text.isNotEmpty) { + input.add({'type': 'text', 'text': text}); + } + for (final attachment in imageAttachments) { + final uploadedPath = await _uploadImageAttachment(attachment); + uploadedImagePaths.add(uploadedPath); + input.add({'type': 'localImage', 'path': uploadedPath}); + } + return _PreparedUserInput( + input: input, + uploadedImagePaths: uploadedImagePaths, + ); + } + + String _addOptimisticUserEntry( + String prompt, + List attachments, + ) { + final text = _composeTextInput( + prompt, + attachments.where((item) => item.isTextFile).toList(), + ); + final imageCount = attachments.where((item) => item.isImage).length; + final body = [ + text.trim(), + if (imageCount > 0) + imageCount == 1 ? '[1 image]' : '[$imageCount images]', + ].where((item) => item.isNotEmpty).join('\n'); + final key = 'local-user-${DateTime.now().microsecondsSinceEpoch}'; + entries.add( + ActivityEntry( + key: key, + kind: EntryKind.user, + title: 'You', + body: body, + isLocalPending: true, + ), + ); + _pendingOptimisticUserEntryKeys.add(key); + notifyListeners(); + return key; + } + + void _discardOptimisticUserEntry(String key) { + _pendingOptimisticUserEntryKeys.remove(key); + entries.removeWhere((entry) => entry.key == key); + } + + Future _uploadImageAttachment(ComposerAttachment attachment) async { + final path = _buildRemoteImageAttachmentPath(attachment.fileName); + await _request('fs/writeFile', { + 'path': path, + 'dataBase64': base64Encode(attachment.bytes), + }, const Duration(minutes: 2)); + return path; + } + + String _buildRemoteImageAttachmentPath(String fileName) { + final baseDirectory = activeThreadCwd.trim().isNotEmpty + ? activeThreadCwd.trim() + : '/tmp'; + final extension = _remoteAttachmentExtension(fileName); + final fileSuffix = + '${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}'; + return _joinRemotePath( + baseDirectory, + '.codex_remote_image_$fileSuffix$extension', + ); + } + + String _remoteAttachmentExtension(String fileName) { + final trimmed = fileName.trim(); + final dotIndex = trimmed.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex == trimmed.length - 1) { + return ''; + } + final extension = trimmed.substring(dotIndex); + return RegExp(r'^\.[A-Za-z0-9]+$').hasMatch(extension) ? extension : ''; + } + + String _joinRemotePath(String directory, String name) { + final separator = directory.contains(r'\') && !directory.contains('/') + ? r'\' + : '/'; + final trimmedDirectory = directory.endsWith(separator) + ? directory.substring(0, directory.length - 1) + : directory; + if (trimmedDirectory.isEmpty) { + return name; + } + return '$trimmedDirectory$separator$name'; + } + + void _trackUploadedImagePaths(String? turnId, List paths) { + if (turnId == null || turnId.isEmpty || paths.isEmpty) { + return; + } + final tracked = _uploadedImagePathsByTurnId.putIfAbsent( + turnId, + () => [], + ); + tracked.addAll(paths); + } + + Future _cleanupUploadedImagePaths(List paths) async { + for (final path in paths) { + try { + await _request('fs/remove', { + 'path': path, + }, const Duration(seconds: 20)); + } catch (_) { + // Ignore cleanup failures for best-effort temp-file removal. + } + } + } + + void _cleanupUploadedImagesForTurn(String? turnId) { + if (turnId == null || turnId.isEmpty) { + return; + } + final paths = _uploadedImagePathsByTurnId.remove(turnId); + if (paths == null || paths.isEmpty) { + return; + } + unawaited(_cleanupUploadedImagePaths(paths)); + } + + ActivityEntry _resolveOptimisticUserEntry(String actualItemId) { + while (_pendingOptimisticUserEntryKeys.isNotEmpty) { + final pendingKey = _pendingOptimisticUserEntryKeys.removeAt(0); + final index = entries.indexWhere((entry) => entry.key == pendingKey); + if (index < 0) { + continue; + } + final pending = entries[index]; + final resolved = ActivityEntry( + key: actualItemId, + kind: EntryKind.user, + title: pending.title, + body: pending.body, + secondary: pending.secondary, + status: pending.status, + timestamp: pending.timestamp, + ); + entries[index] = resolved; + return resolved; + } + return _createEntry(actualItemId, 'userMessage', const { + 'type': 'userMessage', + }); + } + + String _composeTextInput( + String prompt, + List fileAttachments, + ) { + final sections = []; + final trimmed = prompt.trim(); + if (trimmed.isNotEmpty) { + sections.add(trimmed); + } + for (final attachment in fileAttachments) { + final content = attachment.textContent?.trim() ?? ''; + if (content.isEmpty) { + continue; + } + sections.add( + 'Attached file: ${attachment.fileName}\n```text\n$content\n```', + ); + } + return sections.join('\n\n'); + } + + Future resolveApproval( + PendingApproval approval, + String decision, + ) async { + await _send({ + 'id': approval.requestId, + 'result': {'decision': decision}, + }); + approvals.removeWhere((item) => item.requestId == approval.requestId); + notifyListeners(); + } + + Future _refreshUsageMetadata({bool notify = true}) async { + if (!isConnected) { + if (notify) { + notifyListeners(); + } + return; + } + + try { + final rateResponse = await _request( + 'account/rateLimits/read', + null, + const Duration(seconds: 20), + ); + final snapshot = _selectRateLimitSnapshot(rateResponse); + rateLimitSummary = _formatRateLimitSummary(snapshot); + rateLimitResetDetails = _formatRateLimitResetDetails(snapshot); + } catch (_) { + rateLimitSummary = null; + rateLimitResetDetails = const []; + } + + try { + final configResponse = await _request( + 'config/read', + {}, + const Duration(seconds: 20), + ); + final config = configResponse?['config']; + if (config is Map) { + final contextState = _contextStateFromConfig(config); + contextWindowSummary = contextState.$1; + contextUsagePercent = contextState.$2; + } else { + contextWindowSummary = null; + contextUsagePercent = null; + } + } catch (_) { + contextWindowSummary = null; + contextUsagePercent = null; + } + + if (notify) { + notifyListeners(); + } + } + + Future _ensureThread({bool forceNew = false}) async { + if (!forceNew && activeThreadId != null) { + return; + } + + if (!forceNew && _settings.resumeThreadId.trim().isNotEmpty) { + try { + final response = await _request('thread/resume', { + 'threadId': _settings.resumeThreadId.trim(), + }, _threadLoadTimeout); + final thread = response?['thread']; + if (thread is Map) { + final threadId = thread['id'] as String?; + if (threadId != null) { + activeThreadId = threadId; + _subscribedThreadId = threadId; + activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; + activeThreadName = thread['name']?.toString() ?? activeThreadName; + _addSystemEntry( + 'Resumed thread ${activeThreadName?.trim().isNotEmpty == true ? activeThreadName : threadId}.', + ); + notifyListeners(); + _resyncAutomationWatchesForCurrentThread(); + return; + } + } + } catch (_) { + _addSystemEntry( + 'Stored thread ${_settings.resumeThreadId} could not be resumed. Starting a new thread.', + ); + } + } + + final initialCwd = _pendingNewThreadCwd?.trim() ?? ''; + final response = await _request('thread/start', { + if (initialCwd.isNotEmpty) 'cwd': initialCwd, + }); + final thread = response?['thread']; + if (thread is! Map) { + return; + } + final threadId = thread['id'] as String?; + if (threadId == null) { + return; + } + activeThreadId = threadId; + _subscribedThreadId = threadId; + activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; + activeThreadName = thread['name']?.toString() ?? activeThreadName; + _pendingNewThreadCwd = null; + await saveSettings(_settings.copyWith(resumeThreadId: threadId)); + _resyncAutomationWatchesForCurrentThread(); + _addSystemEntry( + 'Opened thread ${activeThreadName?.trim().isNotEmpty == true ? activeThreadName : threadId}.', + ); + } + + Map _buildSandboxPolicy() { + switch (_settings.sandboxMode) { + case SandboxMode.workspaceWrite: + final writableRoots = activeThreadCwd.trim().isEmpty + ? const [] + : [activeThreadCwd.trim()]; + return { + 'type': 'workspaceWrite', + if (writableRoots.isNotEmpty) 'writableRoots': writableRoots, + 'networkAccess': _settings.allowNetwork, + }; + case SandboxMode.readOnly: + return {'type': 'readOnly'}; + case SandboxMode.dangerFullAccess: + return {'type': 'dangerFullAccess'}; + } + } + + Map _buildCommandSandboxPolicy( + SandboxMode sandboxMode, + bool allowNetwork, + String cwd, + ) { + switch (sandboxMode) { + case SandboxMode.workspaceWrite: + final writableRoots = cwd.trim().isEmpty + ? const [] + : [cwd]; + return { + 'type': 'workspaceWrite', + if (writableRoots.isNotEmpty) 'writableRoots': writableRoots, + 'networkAccess': allowNetwork, + }; + case SandboxMode.readOnly: + return {'type': 'readOnly'}; + case SandboxMode.dangerFullAccess: + return {'type': 'dangerFullAccess'}; + } + } + + Future?> _request( + String method, [ + Object? params = const {}, + Duration timeout = const Duration(seconds: 20), + ]) async { + final id = _requestId++; + final completer = Completer?>(); + _pendingRequests[id] = completer; + final paramsField = {'params': params}; + try { + await _send({ + 'id': id, + 'method': method, + ...paramsField, + }); + } catch (_) { + _pendingRequests.remove(id); + rethrow; + } + return completer.future.timeout( + timeout, + onTimeout: () { + _pendingRequests.remove(id); + throw TimeoutException('Request timed out: $method', timeout); + }, + ); + } + + void _notify(String method, [Map? params]) { + final paramsField = params == null + ? null + : {'params': params}; + unawaited(_send({'method': method, ...?paramsField})); + } + + Future _send(Map payload) { + return _transport.send(jsonEncode(payload)); + } + + void _handleSocketMessage(String rawMessage) { + final dynamic decoded = jsonDecode(rawMessage); + if (decoded is! Map) { + return; + } + + final method = decoded['method'] as String?; + final id = decoded['id']; + + if (method == null && id is int) { + _handleResponse(id, decoded); + return; + } + + if (method != null && id is int) { + _handleServerRequest(id, method, decoded['params']); + return; + } + + if (method != null) { + _handleNotification(method, decoded['params']); + } + } + + void _handleResponse(int id, Map message) { + final completer = _pendingRequests.remove(id); + if (completer == null || completer.isCompleted) { + return; + } + + final error = message['error']; + if (error != null) { + completer.completeError(error.toString()); + return; + } + + final result = message['result']; + if (result is Map) { + completer.complete(result); + } else { + completer.complete(null); + } + } + + void _handleServerRequest(int id, String method, dynamic params) { + final typedParams = params is Map + ? params + : {}; + _pushEvent(method, typedParams); + + if (method == 'item/commandExecution/requestApproval' || + method == 'item/fileChange/requestApproval') { + final detail = switch (method) { + 'item/commandExecution/requestApproval' => + typedParams['command']?.toString() ?? + typedParams['reason']?.toString() ?? + '', + _ => + typedParams['reason']?.toString() ?? + typedParams['itemId']?.toString() ?? + '', + }; + final approval = PendingApproval( + requestId: id, + method: method, + itemId: typedParams['itemId']?.toString() ?? '$id', + title: method == 'item/commandExecution/requestApproval' + ? 'Command approval' + : 'File change approval', + detail: detail, + availableDecisions: + (typedParams['availableDecisions'] as List?) + ?.map((item) => item.toString()) + .toList() ?? + const ['accept', 'acceptForSession', 'decline', 'cancel'], + ); + approvals.removeWhere((item) => item.requestId == approval.requestId); + approvals.add(approval); + notifyListeners(); + return; + } + + unawaited( + _send({ + 'id': id, + 'error': { + 'code': -32601, + 'message': 'Unsupported request: $method', + }, + }), + ); + } + + void _handleNotification(String method, dynamic params) { + final typedParams = params is Map + ? params + : {}; + _pushEvent(method, typedParams); + + switch (method) { + case 'android/transportStatus': + final transportStatus = typedParams['status']?.toString() ?? ''; + switch (transportStatus) { + case 'connected': + status = ConnectionStatus.ready; + statusMessage = 'Ready'; + case 'disconnected': + status = ConnectionStatus.disconnected; + statusMessage = 'Disconnected'; + case 'error': + status = ConnectionStatus.error; + statusMessage = 'Connection error'; + } + notifyListeners(); + case 'thread/started': + final thread = typedParams['thread']; + if (thread is Map) { + final threadId = thread['id']?.toString(); + if (threadId != null && activeThreadId == null) { + activeThreadId = threadId; + _subscribedThreadId = threadId; + activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; + activeThreadName = thread['name']?.toString() ?? activeThreadName; + unawaited( + saveSettings(_settings.copyWith(resumeThreadId: threadId)), + ); + _resyncAutomationWatchesForCurrentThread(); + } + } + case 'thread/status/changed': + final threadId = typedParams['threadId']?.toString(); + final status = _statusText(typedParams['status']); + if (threadId != null && status.isNotEmpty) { + final index = threadHistory.indexWhere((item) => item.id == threadId); + if (index >= 0) { + final current = threadHistory[index]; + threadHistory[index] = ThreadSummary( + id: current.id, + preview: current.preview, + cwd: current.cwd, + source: current.source, + modelProvider: current.modelProvider, + createdAt: current.createdAt, + updatedAt: current.updatedAt, + status: status, + name: current.name, + agentNickname: current.agentNickname, + agentRole: current.agentRole, + ); + notifyListeners(); + } + } + case 'turn/started': + final turn = typedParams['turn']; + if (turn is Map) { + final threadId = + typedParams['threadId']?.toString() ?? + activeThreadId?.trim() ?? + ''; + final turnId = turn['id']?.toString(); + if (threadId.isNotEmpty && turnId != null && turnId.isNotEmpty) { + _activeTurnIdsByThread[threadId] = turnId; + } + if (threadId == (activeThreadId?.trim() ?? '')) { + activeTurnId = turnId; + statusMessage = 'Turn running'; + notifyListeners(); + } + } + case 'turn/completed': + final turn = typedParams['turn']; + if (turn is Map) { + final threadId = + typedParams['threadId']?.toString() ?? + activeThreadId?.trim() ?? + ''; + if (threadId.isNotEmpty) { + _activeTurnIdsByThread.remove(threadId); + } + final turnStatus = turn['status']?.toString() ?? 'completed'; + if (threadId == (activeThreadId?.trim() ?? '')) { + activeTurnId = null; + isSteering = false; + statusMessage = 'Ready'; + if (turnStatus != 'completed') { + _addSystemEntry('Turn finished with status $turnStatus.'); + } + final error = turn['error']; + if (error is Map) { + _addSystemEntry(error['message']?.toString() ?? 'Turn failed.'); + } + notifyListeners(); + } + final turnId = + turn['id']?.toString() ?? typedParams['turnId']?.toString() ?? ''; + _cleanupUploadedImagesForTurn(turnId); + if (turnStatus == 'completed' && turnId.isNotEmpty) { + unawaited(_handleAutomationTurnCompleted(turnId)); + } + if (threadId == (activeThreadId?.trim() ?? '')) { + unawaited(_processPendingPrompts()); + } + } + case 'item/started': + final itemThreadId = typedParams['threadId']?.toString() ?? ''; + if (itemThreadId.isEmpty || + itemThreadId == (activeThreadId?.trim() ?? '')) { + _handleItem( + typedParams['item'], + isCompleted: false, + turnId: typedParams['turnId']?.toString(), + ); + } + case 'item/completed': + final itemThreadId = typedParams['threadId']?.toString() ?? ''; + if (itemThreadId.isEmpty || + itemThreadId == (activeThreadId?.trim() ?? '')) { + _handleItem( + typedParams['item'], + isCompleted: true, + turnId: typedParams['turnId']?.toString(), + ); + } + case 'item/agentMessage/delta': + final itemId = typedParams['itemId']?.toString(); + final delta = typedParams['delta']?.toString() ?? ''; + if (itemId != null) { + final entry = _entryByItemId[itemId]; + if (entry != null) { + entry.body += delta; + entry.isStreaming = true; + notifyListeners(); + } + } + case 'item/reasoning/summaryTextDelta': + final itemId = typedParams['itemId']?.toString(); + final delta = typedParams['delta']?.toString() ?? ''; + if (itemId != null) { + final entry = _entryByItemId[itemId]; + if (entry != null) { + entry.body += delta; + notifyListeners(); + } + } + case 'item/commandExecution/outputDelta': + final itemId = typedParams['itemId']?.toString(); + final delta = typedParams['delta']?.toString() ?? ''; + if (itemId != null) { + final entry = _entryByItemId[itemId]; + if (entry != null) { + entry.body += delta; + notifyListeners(); + } + } + case 'command/exec/outputDelta': + final processId = typedParams['processId']?.toString(); + final deltaBase64 = typedParams['deltaBase64']?.toString(); + if (processId != null && deltaBase64 != null) { + final rawBytes = base64Decode(deltaBase64); + final pendingServer = _pendingTransferServersByProcessId[processId]; + if (pendingServer != null) { + final stream = typedParams['stream']?.toString() ?? 'stdout'; + final decoded = utf8.decode(rawBytes, allowMalformed: true); + if (stream == 'stderr') { + pendingServer.stderr += decoded; + } else { + pendingServer.handleStdout(decoded); + } + } + final pendingDownload = _pendingDownloadsByProcessId[processId]; + if (pendingDownload != null) { + final stream = typedParams['stream']?.toString() ?? 'stdout'; + if (stream == 'stderr') { + pendingDownload.stderr += utf8.decode( + rawBytes, + allowMalformed: true, + ); + } + } + final session = _commandSessionsByProcessId[processId]; + if (session != null) { + final decoded = utf8.decode(rawBytes, allowMalformed: true); + final stream = typedParams['stream']?.toString() ?? 'stdout'; + if (stream == 'stderr') { + session.stderr = _appendCommandOutput(session.stderr, decoded); + } else { + session.stdout = _appendCommandOutput(session.stdout, decoded); + } + if (typedParams['capReached'] == true) { + session.outputCapReached = true; + } + notifyListeners(); + } + } + case 'serverRequest/resolved': + final requestId = typedParams['requestId']; + approvals.removeWhere((item) => item.requestId == requestId); + notifyListeners(); + case 'account/rateLimits/updated': + final snapshot = _selectRateLimitSnapshot(typedParams); + rateLimitSummary = _formatRateLimitSummary(snapshot); + rateLimitResetDetails = _formatRateLimitResetDetails(snapshot); + notifyListeners(); + case 'thread/tokenUsage/updated': + final threadId = typedParams['threadId']?.toString() ?? ''; + if (threadId == activeThreadId) { + final tokenUsage = typedParams['tokenUsage']; + if (tokenUsage is Map) { + final contextState = _contextStateFromTokenUsage(tokenUsage); + contextWindowSummary = contextState.$1; + contextUsagePercent = contextState.$2; + } + notifyListeners(); + } + case 'fs/changed': + final watchId = typedParams['watchId']?.toString() ?? ''; + final changedPaths = + (typedParams['changedPaths'] as List? ?? const []) + .map((item) => item.toString()) + .toList(growable: false); + if (watchId.isNotEmpty && changedPaths.isNotEmpty) { + unawaited(_handleAutomationFsChanged(watchId, changedPaths)); + } + case 'error': + final error = typedParams['error']; + if (error is Map) { + _addSystemEntry(error['message']?.toString() ?? 'Server error'); + notifyListeners(); + } + } + } + + void _handleItem(dynamic item, {required bool isCompleted, String? turnId}) { + if (item is! Map) { + return; + } + + final itemId = item['id']?.toString(); + final type = item['type']?.toString() ?? 'unknown'; + if (itemId == null) { + return; + } + + final entry = + _entryByItemId[itemId] ?? + (type == 'userMessage' + ? _resolveOptimisticUserEntry(itemId) + : _createEntry(itemId, type, item)); + _entryByItemId[itemId] = entry; + + switch (type) { + case 'userMessage': + entry.isLocalPending = false; + entry.body = _extractUserText(item['content']); + case 'agentMessage': + entry.body = item['text']?.toString() ?? entry.body; + entry.isStreaming = !isCompleted; + case 'reasoning': + entry.body = _extractReasoningText(item); + case 'commandExecution': + entry.title = item['command']?.toString() ?? entry.title; + entry.secondary = item['cwd']?.toString() ?? entry.secondary; + entry.body = item['aggregatedOutput']?.toString() ?? entry.body; + entry.status = item['status']?.toString() ?? entry.status; + case 'fileChange': + entry.body = _extractFileChanges(item['changes']); + entry.status = item['status']?.toString() ?? entry.status; + case 'mcpToolCall': + case 'collabAgentToolCall': + case 'dynamicToolCall': + case 'webSearch': + case 'plan': + entry.body = _summarizeMap(item); + entry.status = item['status']?.toString() ?? entry.status; + case 'enteredReviewMode': + case 'exitedReviewMode': + entry.body = _summarizeMap(item); + case 'contextCompaction': + entry.body = _extractContextCompactionText(item); + default: + entry.body = _summarizeMap(item); + } + + if (isCompleted) { + entry.isStreaming = false; + final itemStatus = item['status']?.toString(); + if (itemStatus != null && itemStatus.isNotEmpty) { + entry.status = itemStatus; + } + if (type == 'agentMessage' && + item['phase']?.toString() == 'final_answer' && + turnId != null && + turnId.isNotEmpty) { + if (pendingPrompts.isNotEmpty) { + unawaited(_processPendingPrompts()); + } + } + } + + notifyListeners(); + } + + ActivityEntry _createEntry( + String itemId, + String type, + Map item, + ) { + final entry = ActivityEntry( + key: itemId, + kind: switch (type) { + 'userMessage' => EntryKind.user, + 'agentMessage' => EntryKind.agent, + 'reasoning' => EntryKind.reasoning, + 'commandExecution' => EntryKind.command, + 'fileChange' => EntryKind.fileChange, + 'mcpToolCall' || + 'collabAgentToolCall' || + 'dynamicToolCall' || + 'webSearch' || + 'plan' => EntryKind.tool, + _ => EntryKind.system, + }, + title: switch (type) { + 'userMessage' => 'You', + 'agentMessage' => 'Codex', + 'reasoning' => 'Reasoning', + 'commandExecution' => 'Command', + 'fileChange' => 'File change', + 'mcpToolCall' => 'MCP tool', + 'collabAgentToolCall' => 'Collaboration', + 'dynamicToolCall' => 'Dynamic tool', + 'webSearch' => 'Web search', + 'plan' => 'Plan', + 'enteredReviewMode' => 'Review started', + 'exitedReviewMode' => 'Review finished', + 'contextCompaction' => 'Compaction', + _ => type, + }, + body: '', + status: item['status']?.toString() ?? '', + ); + entries.add(entry); + return entry; + } + + String _extractUserText(dynamic content) { + if (content is! List) { + return ''; + } + + return content + .map((item) { + if (item is! Map) { + return ''; + } + if (item['type'] == 'text') { + return item['text']?.toString() ?? ''; + } + if (item['type'] == 'image') { + return '[image] ${item['url'] ?? ''}'; + } + if (item['type'] == 'localImage') { + return '[local image] ${item['path'] ?? ''}'; + } + return ''; + }) + .where((item) => item.isNotEmpty) + .join('\n'); + } + + String _extractReasoningText(Map item) { + final summary = item['summary']; + if (summary is List) { + final text = summary + .map((part) => part?.toString() ?? '') + .where((part) => part.isNotEmpty) + .join('\n'); + if (text.isNotEmpty) { + return text; + } + } + final content = item['content']; + if (content is List) { + return content + .map((part) => part?.toString() ?? '') + .where((part) => part.isNotEmpty) + .join('\n'); + } + return ''; + } + + String _extractFileChanges(dynamic changes) { + if (changes is! List) { + return ''; + } + + return changes + .map((change) { + if (change is! Map) { + return ''; + } + final path = change['path']?.toString() ?? ''; + final kind = change['kind']?.toString() ?? ''; + final diff = change['diff']?.toString() ?? ''; + final header = [ + path, + kind, + ].where((item) => item.isNotEmpty).join(' • '); + return [header, diff].where((item) => item.isNotEmpty).join('\n'); + }) + .where((item) => item.isNotEmpty) + .join('\n\n'); + } + + String _extractContextCompactionText(Map item) { + const candidates = [ + 'label', + 'message', + 'text', + 'summary', + 'description', + ]; + for (final key in candidates) { + final value = item[key]; + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty) { + return text; + } + } + return 'Context Compacting'; + } + + String _summarizeMap(Map item) { + final copy = Map.from(item)..remove('id'); + return const JsonEncoder.withIndent(' ').convert(copy); + } + + void _addSystemEntry(String message) { + entries.add( + ActivityEntry( + key: 'system-${DateTime.now().microsecondsSinceEpoch}', + kind: EntryKind.system, + title: 'System', + body: message, + ), + ); + notifyListeners(); + } + + void _pushEvent(String method, Map params) { + final summary = switch (method) { + 'item/agentMessage/delta' => params['delta']?.toString() ?? '', + 'item/commandExecution/outputDelta' => params['delta']?.toString() ?? '', + _ => _singleLineSummary(params), + }; + eventLog.insert(0, EventLogEntry(method, summary)); + if (eventLog.length > 60) { + eventLog.removeRange(60, eventLog.length); + } + } + + String _singleLineSummary(Map params) { + if (params.isEmpty) { + return ''; + } + final text = const JsonEncoder.withIndent(' ').convert(params); + return text.replaceAll('\n', ' ').trim(); + } + + CommandSession? get activeCommandSession { + if (activeCommandSessionId == null) { + return commandSessions.isEmpty ? null : commandSessions.first; + } + return _commandSessionsById[activeCommandSessionId!] ?? + (commandSessions.isEmpty ? null : commandSessions.first); + } + + void _completeCommandSession( + CommandSession session, + Map? result, + ) { + session.exitCode = result?['exitCode'] as int?; + final stdout = result?['stdout']?.toString() ?? ''; + final stderr = result?['stderr']?.toString() ?? ''; + if (stdout.isNotEmpty) { + session.stdout = _appendCommandOutput(session.stdout, stdout); + } + if (stderr.isNotEmpty) { + session.stderr = _appendCommandOutput(session.stderr, stderr); + } + session.status = session.exitCode == 0 ? 'completed' : 'failed'; + notifyListeners(); + } + + Future _rememberRecentCommand(RecentCommand next) async { + recentCommands.removeWhere( + (RecentCommand current) => _sameRecentCommand(current, next), + ); + recentCommands.insert(0, next); + if (recentCommands.length > 8) { + recentCommands.removeRange(8, recentCommands.length); + } + await _settingsStore.saveRecentCommands(recentCommands); + notifyListeners(); + } + + bool _sameRecentCommand(RecentCommand left, RecentCommand right) { + return left.commandText == right.commandText && + left.cwd == right.cwd && + left.mode == right.mode && + left.sandboxMode == right.sandboxMode && + left.allowNetwork == right.allowNetwork && + left.disableTimeout == right.disableTimeout && + left.timeoutMs == right.timeoutMs && + left.disableOutputCap == right.disableOutputCap && + left.outputBytesCap == right.outputBytesCap; + } + + Map? _selectRateLimitSnapshot( + Map? response, + ) { + if (response == null) { + return null; + } + final byLimitId = response['rateLimitsByLimitId']; + if (byLimitId is Map && byLimitId.isNotEmpty) { + final preferred = byLimitId['codex']; + if (preferred is Map) { + return preferred; + } + for (final value in byLimitId.values) { + if (value is Map) { + return value; + } + } + } + final snapshot = response['rateLimits']; + return snapshot is Map ? snapshot : null; + } + + String? _formatRateLimitSummary(Map? snapshot) { + if (snapshot == null) { + return null; + } + final segments = []; + final primary = snapshot['primary']; + if (primary is Map) { + final label = _formatRateLimitWindow(primary); + if (label != null) { + segments.add(label); + } + } + final secondary = snapshot['secondary']; + if (secondary is Map) { + final label = _formatRateLimitWindow(secondary); + if (label != null) { + segments.add(label); + } + } + final credits = snapshot['credits']; + if (segments.isEmpty && credits is Map) { + if (credits['unlimited'] == true) { + return 'unlimited'; + } + final balance = credits['balance']?.toString(); + if (balance != null && balance.isNotEmpty) { + return 'Credits $balance'; + } + } + if (segments.isEmpty) { + return null; + } + return segments.join(' • '); + } + + List _formatRateLimitResetDetails(Map? snapshot) { + if (snapshot == null) { + return const []; + } + final details = []; + final primary = snapshot['primary']; + if (primary is Map) { + final detail = _formatRateLimitResetDetail(primary); + if (detail != null) { + details.add(detail); + } + } + final secondary = snapshot['secondary']; + if (secondary is Map) { + final detail = _formatRateLimitResetDetail(secondary); + if (detail != null) { + details.add(detail); + } + } + return details; + } + + String? _formatRateLimitWindow(Map window) { + final usedPercent = _parsePositiveInt(window['usedPercent']); + if (usedPercent == null) { + return null; + } + final remaining = (100 - usedPercent).clamp(0, 100); + final duration = window['windowDurationMins']; + final durationLabel = _formatWindowDuration(duration); + return durationLabel == null + ? '$remaining% left' + : '$durationLabel $remaining% left'; + } + + String? _formatRateLimitResetDetail(Map window) { + final durationLabel = _formatWindowDuration(window['windowDurationMins']); + final resetAt = _parseRateLimitResetAt(window); + if (durationLabel == null || resetAt == null) { + return null; + } + return '$durationLabel resets ${_formatRateLimitResetAt(resetAt)}'; + } + + (String?, int?) _contextStateFromConfig(Map config) { + final contextWindow = _parsePositiveInt(config['model_context_window']); + final compactLimit = _parsePositiveInt( + config['model_auto_compact_token_limit'], + ); + if (contextWindow == null && compactLimit == null) { + return (null, null); + } + if (contextWindow != null) { + return ('${_formatTokenCount(contextWindow)} window', null); + } + if (compactLimit != null) { + return ('${_formatTokenCount(compactLimit)} compact', null); + } + return (null, null); + } + + (String?, int?) _contextStateFromTokenUsage(Map tokenUsage) { + final last = tokenUsage['last']; + final lastMap = last is Map ? last : null; + final lastTurnTokens = _parsePositiveInt(lastMap?['totalTokens']); + final contextWindow = _parsePositiveInt(tokenUsage['modelContextWindow']); + if (lastTurnTokens == null && contextWindow == null) { + return (null, null); + } + if (lastTurnTokens != null && contextWindow != null && contextWindow > 0) { + final usedPercent = ((lastTurnTokens / contextWindow) * 100) + .round() + .clamp(0, 100); + return ('$usedPercent% last/window', usedPercent); + } + if (lastTurnTokens != null) { + return ('${_formatTokenCount(lastTurnTokens)} last', null); + } + return ('${_formatTokenCount(contextWindow!)} window', null); + } + + int? _parsePositiveInt(dynamic value) { + if (value is int) { + return value > 0 ? value : null; + } + if (value is num) { + final asInt = value.round(); + return asInt > 0 ? asInt : null; + } + if (value is String) { + final parsed = int.tryParse(value.trim()); + if (parsed != null && parsed > 0) { + return parsed; + } + } + return null; + } + + DateTime? _parseRateLimitResetAt(Map window) { + const candidates = [ + 'resetsAt', + 'resetAt', + 'resetsAtIso', + 'resetAtIso', + 'resetsAtUnixMs', + 'resetAtUnixMs', + 'resetsAtMs', + 'resetAtMs', + 'resetsAtUnix', + 'resetAtUnix', + ]; + for (final key in candidates) { + final value = window[key]; + if (value == null) { + continue; + } + if (value is String) { + final parsed = DateTime.tryParse(value.trim()); + if (parsed != null) { + return parsed.toLocal(); + } + final asInt = int.tryParse(value.trim()); + if (asInt != null) { + return _dateTimeFromEpochGuess(asInt); + } + } + if (value is int) { + return _dateTimeFromEpochGuess(value); + } + if (value is num) { + return _dateTimeFromEpochGuess(value.round()); + } + } + return null; + } + + DateTime _dateTimeFromEpochGuess(int value) { + final isMilliseconds = value.abs() >= 100000000000; + return isMilliseconds + ? DateTime.fromMillisecondsSinceEpoch(value).toLocal() + : DateTime.fromMillisecondsSinceEpoch(value * 1000).toLocal(); + } + + String? _formatWindowDuration(dynamic minutesValue) { + if (minutesValue is! int || minutesValue <= 0) { + return null; + } + if (minutesValue % 1440 == 0) { + return '${minutesValue ~/ 1440}d'; + } + if (minutesValue % 60 == 0) { + return '${minutesValue ~/ 60}h'; + } + return '${minutesValue}m'; + } + + String _formatRateLimitResetAt(DateTime value) { + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final month = monthNames[value.month - 1]; + final day = value.day.toString().padLeft(2, '0'); + final hour = value.hour.toString().padLeft(2, '0'); + final minute = value.minute.toString().padLeft(2, '0'); + return '$month $day, $hour:$minute'; + } + + String _formatTokenCount(int value) { + if (value >= 1000000) { + final millions = value / 1000000; + final text = millions.toStringAsFixed( + millions.truncateToDouble() == millions ? 0 : 1, + ); + return '${text}M'; + } + if (value >= 1000) { + final thousands = value / 1000; + final text = thousands.toStringAsFixed( + thousands.truncateToDouble() == thousands ? 0 : 1, + ); + return '${text}k'; + } + return value.toString(); + } + + String _normalizeCommandOutput(String value) { + final csiPattern = RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'); + final oscPattern = RegExp(r'\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)'); + final withoutOsc = value.replaceAll(oscPattern, ''); + final withoutAnsi = withoutOsc.replaceAll(csiPattern, ''); + final terminalText = _applyTerminalControls(withoutAnsi); + return _collapseSingleCharacterLines(terminalText); + } + + String _appendCommandOutput(String existing, String nextChunk) { + return _normalizeCommandOutput(existing + nextChunk); + } + + String _collapseSingleCharacterLines(String value) { + final lines = value.split('\n'); + if (lines.length < 8) { + return value; + } + + final collapsed = []; + final singleCharRun = []; + + void flushRun() { + if (singleCharRun.isEmpty) { + return; + } + final nonEmpty = singleCharRun.where((line) => line.isNotEmpty).toList(); + final mostlySingleChar = + nonEmpty.length >= 6 && + nonEmpty.every((line) { + final trimmed = line.trim(); + return line.runes.length == 1 || trimmed.runes.length == 1; + }); + if (mostlySingleChar) { + final joined = singleCharRun.join(); + if (collapsed.isNotEmpty && joined.startsWith(RegExp(r'\s'))) { + collapsed[collapsed.length - 1] = '${collapsed.last}$joined'; + } else { + collapsed.add(joined); + } + } else { + collapsed.addAll(singleCharRun); + } + singleCharRun.clear(); + } + + for (final line in lines) { + final trimmed = line.trim(); + final isRepairableSingleChar = + line.isEmpty || line.runes.length == 1 || trimmed.runes.length == 1; + if (isRepairableSingleChar) { + singleCharRun.add(line); + } else { + flushRun(); + collapsed.add(line); + } + } + flushRun(); + + return collapsed.join('\n'); + } + + String _applyTerminalControls(String value) { + final lines = []; + var currentLine = []; + var cursor = 0; + + void writeChar(String char) { + if (cursor > currentLine.length) { + currentLine.addAll( + List.filled(cursor - currentLine.length, ' '), + ); + } + if (cursor == currentLine.length) { + currentLine.add(char); + } else { + currentLine[cursor] = char; + } + cursor += 1; + } + + void commitLine() { + lines.add(currentLine.join()); + currentLine = []; + cursor = 0; + } + + for (final rune in value.runes) { + if (rune == 10) { + commitLine(); + continue; + } + if (rune == 13) { + cursor = 0; + continue; + } + if (rune == 8) { + if (cursor > 0) { + cursor -= 1; + } + continue; + } + if (rune == 9) { + final spaces = 4 - (cursor % 4); + for (var index = 0; index < spaces; index += 1) { + writeChar(' '); + } + continue; + } + final isControl = rune < 32 || (rune >= 127 && rune <= 159); + if (!isControl) { + writeChar(String.fromCharCode(rune)); + } + } + + lines.add(currentLine.join()); + return lines.join('\n'); + } + + ThreadSummary? _parseThreadSummary(dynamic item) { + if (item is! Map) { + return null; + } + + return ThreadSummary( + id: item['id']?.toString() ?? '', + preview: item['preview']?.toString() ?? '', + cwd: item['cwd']?.toString() ?? '', + source: _sourceText(item['source']), + modelProvider: item['modelProvider']?.toString() ?? '', + createdAt: _parseUnixTimestamp(item['createdAt']), + updatedAt: _parseUnixTimestamp(item['updatedAt']), + status: _statusText(item['status']), + name: item['name']?.toString(), + agentNickname: item['agentNickname']?.toString(), + agentRole: item['agentRole']?.toString(), + ); + } + + DateTime? _parseUnixTimestamp(dynamic value) { + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch( + value * 1000, + isUtc: true, + ).toLocal(); + } + return null; + } + + String _statusText(dynamic status) { + if (status is Map) { + return status['type']?.toString() ?? ''; + } + return status?.toString() ?? ''; + } + + String _sourceText(dynamic source) { + if (source is Map) { + return source['type']?.toString() ?? source.toString(); + } + return source?.toString() ?? ''; + } + + void _updateThreadSummaryName(String threadId, String name) { + final index = threadHistory.indexWhere((item) => item.id == threadId); + if (index < 0) { + return; + } + final current = threadHistory[index]; + threadHistory[index] = ThreadSummary( + id: current.id, + preview: current.preview, + cwd: current.cwd, + source: current.source, + modelProvider: current.modelProvider, + createdAt: current.createdAt, + updatedAt: current.updatedAt, + status: current.status, + name: name, + agentNickname: current.agentNickname, + agentRole: current.agentRole, + ); + _sortThreadHistory(); + } + + void _sortThreadHistory() { + threadHistory.sort((a, b) { + final aFavorite = isThreadFavorite(a.id); + final bFavorite = isThreadFavorite(b.id); + if (aFavorite != bFavorite) { + return aFavorite ? -1 : 1; + } + final aUpdated = a.updatedAt ?? a.createdAt; + final bUpdated = b.updatedAt ?? b.createdAt; + if (aUpdated != null && bUpdated != null) { + return bUpdated.compareTo(aUpdated); + } + if (aUpdated != null) { + return -1; + } + if (bUpdated != null) { + return 1; + } + return a.title.toLowerCase().compareTo(b.title.toLowerCase()); + }); + } + + String _normalizeAbsolutePath(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return ''; + } + if (!trimmed.startsWith('/')) { + return ''; + } + if (trimmed.length > 1 && trimmed.endsWith('/')) { + return trimmed.substring(0, trimmed.length - 1); + } + return trimmed; + } + + Future _unsubscribeFromThread(String threadId) async { + final normalizedThreadId = threadId.trim(); + if (normalizedThreadId.isEmpty || !isConnected) { + return; + } + if (_subscribedThreadId != normalizedThreadId && + activeThreadId != normalizedThreadId) { + return; + } + try { + await _request('thread/unsubscribe', { + 'threadId': normalizedThreadId, + }); + } catch (_) { + // Best-effort cleanup. A failed unsubscribe should not block switching threads. + } finally { + if (_subscribedThreadId == normalizedThreadId) { + _subscribedThreadId = null; + } + } + } + + bool _isLikelyHumanReadableFile(String path, Uint8List bytes) { + const textExtensions = { + 'txt', + 'md', + 'markdown', + 'json', + 'yaml', + 'yml', + 'toml', + 'xml', + 'html', + 'css', + 'js', + 'ts', + 'tsx', + 'jsx', + 'dart', + 'kt', + 'java', + 'swift', + 'm', + 'mm', + 'c', + 'cc', + 'cpp', + 'h', + 'hpp', + 'rs', + 'go', + 'py', + 'rb', + 'php', + 'sh', + 'zsh', + 'bash', + 'fish', + 'sql', + 'csv', + 'log', + 'ini', + 'cfg', + 'conf', + 'env', + 'gitignore', + 'pubspec', + 'lock', + }; + + final segments = path.split('/'); + final fileName = segments.isEmpty ? path : segments.last; + final extension = fileName.contains('.') + ? fileName.split('.').last.toLowerCase() + : fileName.toLowerCase(); + if (textExtensions.contains(extension)) { + return true; + } + + if (bytes.isEmpty) { + return true; + } + + var suspicious = 0; + final sampleSize = bytes.length > 1024 ? 1024 : bytes.length; + for (var i = 0; i < sampleSize; i += 1) { + final unit = bytes[i]; + if (unit == 0) { + return false; + } + final isControl = unit < 32 && unit != 9 && unit != 10 && unit != 13; + if (isControl) { + suspicious += 1; + } + } + return suspicious <= sampleSize * 0.02; + } + + void _hydrateEntriesFromThread(Map thread) { + final hydratedEntries = []; + final turns = thread['turns']; + if (turns is List) { + for (final turn in turns) { + if (turn is! Map) { + continue; + } + final items = turn['items']; + if (items is! List) { + continue; + } + for (final item in items) { + final entry = _entryFromHistoryItem(item); + if (entry != null) { + hydratedEntries.add(entry); + } + } + } + } + + entries + ..clear() + ..addAll(hydratedEntries); + approvals.clear(); + _entryByItemId + ..clear() + ..addEntries( + hydratedEntries.map( + (item) => MapEntry(item.key, item), + ), + ); + activeTurnId = null; + activeThreadCwd = thread['cwd']?.toString() ?? activeThreadCwd; + } + + void _hydrateResumeTurn(Map turn) { + final turnId = turn['id']?.toString(); + if (turnId == null || turnId.isEmpty) { + return; + } + + final items = turn['items']; + if (items is List) { + for (final item in items) { + final itemMap = item is Map ? item : null; + if (itemMap == null) { + continue; + } + _handleItem( + itemMap, + isCompleted: turn['status']?.toString() != 'inProgress', + turnId: turnId, + ); + } + } + + if (turn['status']?.toString() == 'inProgress') { + activeTurnId = turnId; + final threadId = activeThreadId?.trim() ?? ''; + if (threadId.isNotEmpty) { + _activeTurnIdsByThread[threadId] = turnId; + } + statusMessage = 'Turn running'; + } else if (activeTurnId == turnId) { + activeTurnId = null; + final threadId = activeThreadId?.trim() ?? ''; + if (threadId.isNotEmpty) { + _activeTurnIdsByThread.remove(threadId); + } + statusMessage = 'Ready'; + } + } + + ActivityEntry? _entryFromHistoryItem(dynamic item) { + if (item is! Map) { + return null; + } + final itemId = item['id']?.toString(); + final type = item['type']?.toString() ?? 'unknown'; + if (itemId == null || itemId.isEmpty) { + return null; + } + + final entry = ActivityEntry( + key: itemId, + kind: switch (type) { + 'userMessage' => EntryKind.user, + 'agentMessage' => EntryKind.agent, + 'reasoning' => EntryKind.reasoning, + 'commandExecution' => EntryKind.command, + 'fileChange' => EntryKind.fileChange, + 'mcpToolCall' || + 'collabAgentToolCall' || + 'dynamicToolCall' || + 'webSearch' || + 'plan' => EntryKind.tool, + _ => EntryKind.system, + }, + title: switch (type) { + 'userMessage' => 'You', + 'agentMessage' => 'Codex', + 'reasoning' => 'Reasoning', + 'commandExecution' => item['command']?.toString() ?? 'Command', + 'fileChange' => 'File change', + 'mcpToolCall' => 'MCP tool', + 'collabAgentToolCall' => 'Collaboration', + 'dynamicToolCall' => 'Dynamic tool', + 'webSearch' => 'Web search', + 'plan' => 'Plan', + _ => type, + }, + secondary: item['cwd']?.toString() ?? '', + status: item['status']?.toString() ?? '', + ); + + switch (type) { + case 'userMessage': + entry.body = _extractUserText(item['content']); + case 'agentMessage': + entry.body = item['text']?.toString() ?? ''; + case 'reasoning': + entry.body = _extractReasoningText(item); + case 'commandExecution': + entry.body = item['aggregatedOutput']?.toString() ?? ''; + case 'fileChange': + entry.body = _extractFileChanges(item['changes']); + default: + entry.body = _summarizeMap(item); + } + + return entry; + } + + static Future _defaultOpenPath(String path) async { + if (Platform.isAndroid) { + final normalizedPath = path.trim().toLowerCase(); + if (normalizedPath.isNotEmpty && + (normalizedPath.startsWith('/storage/') || + normalizedPath.startsWith('/sdcard/'))) { + final storageStatus = await Permission.manageExternalStorage.status; + if (!storageStatus.isGranted) { + final requested = await Permission.manageExternalStorage.request(); + if (!requested.isGranted) { + await openAppSettings(); + return false; + } + } + } + if (normalizedPath.endsWith('.apk')) { + final installStatus = await Permission.requestInstallPackages.status; + if (!installStatus.isGranted) { + final requested = await Permission.requestInstallPackages.request(); + if (!requested.isGranted) { + await openAppSettings(); + return false; + } + } + } + } + final result = await OpenFilex.open(path); + return result.type == ResultType.done; + } + + _RelayPairingCodePayload _decodeRelayPairingCode(String value) { + const prefix = 'crp1.'; + final trimmed = value.trim(); + if (!trimmed.startsWith(prefix)) { + throw StateError('Unsupported pairing code format.'); + } + final payload = + jsonDecode(utf8.decode(_b64urlDecode(trimmed.substring(prefix.length)))) + as Map; + if (payload['type']?.toString() != 'codex-remote-pairing-v1') { + throw StateError('Unsupported pairing code payload.'); + } + final relayUrl = payload['relayUrl']?.toString() ?? ''; + final deviceId = payload['deviceId']?.toString() ?? ''; + final claimToken = payload['claimToken']?.toString() ?? ''; + final bridgeSigningPublicKey = + payload['bridgeSigningPublicKey']?.toString() ?? ''; + final bridgeLabel = payload['bridgeLabel']?.toString() ?? ''; + final expiresAt = payload['expiresAt'] as int? ?? 0; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final relayUri = Uri.tryParse(relayUrl); + if (relayUrl.isEmpty || + deviceId.isEmpty || + claimToken.isEmpty || + bridgeSigningPublicKey.isEmpty) { + throw StateError('Pairing code is incomplete.'); + } + if (relayUri == null || !relayUri.hasScheme || relayUri.host.isEmpty) { + throw StateError('Pairing code contains an invalid relay URL.'); + } + if (!_isAllowedRelayUri(relayUri)) { + throw StateError( + 'Relay pairing requires HTTPS for non-local relay servers.', + ); + } + if (expiresAt != 0 && expiresAt < now) { + throw StateError('Pairing code has expired.'); + } + return _RelayPairingCodePayload( + bridgeLabel: bridgeLabel, + bridgeSigningPublicKey: bridgeSigningPublicKey, + claimToken: claimToken, + deviceId: deviceId, + relayUrl: relayUrl, + ); + } +} + +class _RelayPairingCodePayload { + const _RelayPairingCodePayload({ + required this.bridgeLabel, + required this.bridgeSigningPublicKey, + required this.claimToken, + required this.deviceId, + required this.relayUrl, + }); + + final String bridgeLabel; + final String bridgeSigningPublicKey; + final String claimToken; + final String deviceId; + final String relayUrl; +} + +String _b64urlEncode(List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); +} + +Uint8List _b64urlDecode(String value) { + final normalized = value.padRight((value.length + 3) ~/ 4 * 4, '='); + return Uint8List.fromList(base64Url.decode(normalized)); +} + +bool _isAllowedRelayUri(Uri relayUri) { + if (relayUri.scheme == 'https') { + return true; + } + if (relayUri.scheme != 'http') { + return false; + } + final host = relayUri.host.toLowerCase(); + return host == 'localhost' || + host == '127.0.0.1' || + host == '::1' || + host.endsWith('.local'); +} + +class _ActiveAutomationWatch { + const _ActiveAutomationWatch({ + required this.automationId, + required this.watchId, + required this.path, + required this.kind, + }); + + final String automationId; + final String watchId; + final String path; + final AutomationNodeKind kind; +} + +class _RegisteredAutomationWatch { + const _RegisteredAutomationWatch({required this.watchId, required this.path}); + + final String watchId; + final String path; +} + +class _AutomationExecutionContext { + _AutomationExecutionContext({ + required this.changedPaths, + required this.watchedPath, + required this.triggerKind, + }); + + final List changedPaths; + final String watchedPath; + final AutomationNodeKind triggerKind; + String? lastDownloadedPath; + final Map> nodeOutputs = + >{}; + String? _previousNodeId; + + void recordNodeOutput(String nodeId, Map values) { + nodeOutputs[nodeId] = values; + _previousNodeId = nodeId; + final downloadedPath = values['downloadedPath']?.trim() ?? ''; + if (downloadedPath.isNotEmpty) { + lastDownloadedPath = downloadedPath; + } + } + + String? valueForToken(String token) { + if (token.isEmpty) { + return null; + } + final firstChangedPath = changedPaths.isEmpty ? '' : changedPaths.first; + switch (token) { + case 'trigger.path': + case 'trigger.watchedPath': + return watchedPath; + case 'trigger.changedPath': + return firstChangedPath; + case 'automation.lastDownloadedPath': + case 'lastDownloadedPath': + return lastDownloadedPath; + } + + if (token.startsWith('previous.')) { + final previousNodeId = _previousNodeId; + if (previousNodeId == null) { + return null; + } + return nodeOutputs[previousNodeId]?[token.substring('previous.'.length)]; + } + + if (token.startsWith('node.')) { + final parts = token.split('.'); + if (parts.length >= 3) { + final nodeId = parts[1]; + final key = parts.sublist(2).join('.'); + return nodeOutputs[nodeId]?[key]; + } + } + + return null; + } +} + +class _PendingDownload { + _PendingDownload({required this.expectedBytes, required this.onProgress}) + : _startedAt = DateTime.now(); + + final int? expectedBytes; + final ValueChanged? onProgress; + final DateTime _startedAt; + String stderr = ''; + bool isCancelled = false; + int writtenBytes = 0; + bool _processExited = false; + final Completer _completion = Completer(); + + void cancel() { + isCancelled = true; + _completeIfReady(); + } + + void markProcessExited() { + _processExited = true; + _completeIfReady(); + } + + void reportProgress() { + final callback = onProgress; + if (callback == null) { + _completeIfReady(); + return; + } + final total = expectedBytes; + if (total == null || total <= 0) { + callback( + FileDownloadStatus( + progress: 0.8, + receivedBytes: writtenBytes, + totalBytes: null, + eta: null, + ), + ); + _completeIfReady(); + return; + } + final progress = writtenBytes / total; + final safeProgress = progress.clamp(0.0, 0.95); + final elapsed = DateTime.now().difference(_startedAt); + Duration? eta; + if (writtenBytes > 0 && + elapsed.inMilliseconds > 0 && + writtenBytes < total) { + final bytesPerMs = writtenBytes / elapsed.inMilliseconds; + if (bytesPerMs > 0) { + final remainingMs = ((total - writtenBytes) / bytesPerMs).round(); + eta = Duration(milliseconds: remainingMs); + } + } + callback( + FileDownloadStatus( + progress: safeProgress, + receivedBytes: writtenBytes, + totalBytes: total, + eta: eta, + ), + ); + _completeIfReady(); + } + + void addBytes(int count) { + writtenBytes += count; + reportProgress(); + } + + Future waitForCompletion() async { + _completeIfReady(); + if (_completion.isCompleted) { + return; + } + await _completion.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + if (_completion.isCompleted) { + return; + } + if (isCancelled) { + _completion.complete(); + return; + } + final total = expectedBytes; + if (total != null && total > 0 && writtenBytes != total) { + _completion.completeError( + StateError( + 'Download truncated: expected $total bytes, received $writtenBytes bytes.', + ), + ); + return; + } + _completion.complete(); + }, + ); + } + + void _completeIfReady() { + if (_completion.isCompleted) { + return; + } + if (isCancelled) { + _completion.complete(); + return; + } + if (!_processExited) { + return; + } + final total = expectedBytes; + if (total != null && total > 0) { + if (writtenBytes >= total) { + _completion.complete(); + } + return; + } + _completion.complete(); + } +} + +class _PendingTransferServer { + final Completer<_TransferEndpoint> _ready = Completer<_TransferEndpoint>(); + final StringBuffer _stdoutBuffer = StringBuffer(); + String stderr = ''; + + void handleStdout(String chunk) { + _stdoutBuffer.write(chunk); + final lines = _stdoutBuffer.toString().split('\n'); + if (!chunk.endsWith('\n')) { + final trailing = lines.removeLast(); + _stdoutBuffer + ..clear() + ..write(trailing); + } else { + _stdoutBuffer.clear(); + } + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map && + decoded['event'] == 'ready' && + decoded['port'] is int && + decoded['token'] is String && + !_ready.isCompleted) { + _ready.complete( + _TransferEndpoint( + port: decoded['port'] as int, + token: decoded['token'] as String, + ), + ); + return; + } + } catch (_) { + // Ignore unrelated command output. + } + } + } + + Future<_TransferEndpoint> waitForReady() { + return _ready.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException( + 'Temporary download server did not become ready.', + const Duration(seconds: 10), + ); + }, + ); + } +} + +class FileDownloadStatus { + const FileDownloadStatus({ + required this.progress, + required this.receivedBytes, + required this.totalBytes, + required this.eta, + }); + + final double progress; + final int receivedBytes; + final int? totalBytes; + final Duration? eta; +} + +enum DownloadState { running, completed, failed, cancelled } + +class DownloadRecord { + const DownloadRecord({ + required this.sourcePath, + required this.fileName, + required this.state, + required this.startedAt, + this.status, + this.targetPath, + this.error, + this.finishedAt, + }); + + final String sourcePath; + final String fileName; + final String? targetPath; + final DownloadState state; + final FileDownloadStatus? status; + final String? error; + final DateTime startedAt; + final DateTime? finishedAt; + + DownloadRecord copyWith({ + String? sourcePath, + String? fileName, + String? targetPath, + DownloadState? state, + FileDownloadStatus? status, + String? error, + DateTime? startedAt, + DateTime? finishedAt, + }) { + return DownloadRecord( + sourcePath: sourcePath ?? this.sourcePath, + fileName: fileName ?? this.fileName, + targetPath: targetPath ?? this.targetPath, + state: state ?? this.state, + status: status ?? this.status, + error: error ?? this.error, + startedAt: startedAt ?? this.startedAt, + finishedAt: finishedAt ?? this.finishedAt, + ); + } +} + +class _DirectoryCacheEntry { + const _DirectoryCacheEntry({required this.entries, required this.loadedAt}); + + final List entries; + final DateTime loadedAt; +} + +class _FilePreviewCacheEntry { + const _FilePreviewCacheEntry({ + required this.bytes, + required this.content, + required this.isHumanReadable, + required this.loadedAt, + }); + + final Uint8List bytes; + final String? content; + final bool isHumanReadable; + final DateTime loadedAt; +} + +class _PreparedUserInput { + const _PreparedUserInput({ + required this.input, + required this.uploadedImagePaths, + }); + + final List> input; + final List uploadedImagePaths; +} + +class _TransferEndpoint { + const _TransferEndpoint({required this.port, required this.token}); + + final int port; + final String token; +} + +class _DownloadCancelled implements Exception { + const _DownloadCancelled(); +} diff --git a/lib/src/core/infrastructure/settings_store.dart b/lib/src/core/infrastructure/settings_store.dart new file mode 100644 index 0000000..665d4fe --- /dev/null +++ b/lib/src/core/infrastructure/settings_store.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../models.dart'; + +class SettingsStore { + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final defaults = AppSettings.defaults(); + return defaults.copyWith( + connectionMode: ConnectionMode.values.byName( + prefs.getString(_connectionModeKey) ?? defaults.connectionMode.name, + ), + serverUrl: prefs.getString(_serverUrlKey) ?? defaults.serverUrl, + websocketBearerToken: + prefs.getString(_websocketBearerTokenKey) ?? + defaults.websocketBearerToken, + relayUrl: prefs.getString(_relayUrlKey) ?? defaults.relayUrl, + relayDeviceId: + prefs.getString(_relayDeviceIdKey) ?? defaults.relayDeviceId, + relayBridgeLabel: + prefs.getString(_relayBridgeLabelKey) ?? defaults.relayBridgeLabel, + relayBridgeSigningPublicKey: + prefs.getString(_relayBridgeSigningPublicKeyKey) ?? + defaults.relayBridgeSigningPublicKey, + relayClientPrivateKey: + prefs.getString(_relayClientPrivateKeyKey) ?? + defaults.relayClientPrivateKey, + relayClientPublicKey: + prefs.getString(_relayClientPublicKeyKey) ?? + defaults.relayClientPublicKey, + model: prefs.getString(_modelKey) ?? defaults.model, + reasoningEffort: + prefs.getString(_reasoningEffortKey) ?? defaults.reasoningEffort, + planMode: prefs.getBool(_planModeKey) ?? defaults.planMode, + approvalPolicy: normalizeApprovalPolicy( + prefs.getString(_approvalPolicyKey) ?? defaults.approvalPolicy, + ), + sandboxMode: SandboxMode.values.byName( + prefs.getString(_sandboxModeKey) ?? defaults.sandboxMode.name, + ), + allowNetwork: prefs.getBool(_allowNetworkKey) ?? defaults.allowNetwork, + themePreference: ThemePreference.values.byName( + prefs.getString(_themePreferenceKey) ?? defaults.themePreference.name, + ), + threadLoadTimeoutMs: + prefs.getInt(_threadLoadTimeoutMsKey) ?? defaults.threadLoadTimeoutMs, + resumeThreadId: + prefs.getString(_resumeThreadIdKey) ?? defaults.resumeThreadId, + favoriteThreadIds: _readFavoriteThreadIds(prefs), + threadDownloadDirectories: _readThreadDownloadDirectories(prefs), + automationSnapshots: _readAutomationSnapshots(prefs), + automations: _readAutomations(prefs), + ); + } + + Future save(AppSettings settings) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_connectionModeKey, settings.connectionMode.name); + await prefs.setString(_serverUrlKey, settings.serverUrl); + await prefs.setString( + _websocketBearerTokenKey, + settings.websocketBearerToken, + ); + await prefs.setString(_relayUrlKey, settings.relayUrl); + await prefs.setString(_relayDeviceIdKey, settings.relayDeviceId); + await prefs.setString(_relayBridgeLabelKey, settings.relayBridgeLabel); + await prefs.setString( + _relayBridgeSigningPublicKeyKey, + settings.relayBridgeSigningPublicKey, + ); + await prefs.setString( + _relayClientPrivateKeyKey, + settings.relayClientPrivateKey, + ); + await prefs.setString( + _relayClientPublicKeyKey, + settings.relayClientPublicKey, + ); + await prefs.remove(_cwdKey); + await prefs.setString(_modelKey, settings.model); + await prefs.setString(_reasoningEffortKey, settings.reasoningEffort); + await prefs.setBool(_planModeKey, settings.planMode); + await prefs.setString( + _approvalPolicyKey, + normalizeApprovalPolicy(settings.approvalPolicy), + ); + await prefs.setString(_sandboxModeKey, settings.sandboxMode.name); + await prefs.setBool(_allowNetworkKey, settings.allowNetwork); + await prefs.setString(_themePreferenceKey, settings.themePreference.name); + await prefs.setInt(_threadLoadTimeoutMsKey, settings.threadLoadTimeoutMs); + await prefs.setString(_resumeThreadIdKey, settings.resumeThreadId); + await prefs.setString( + _favoriteThreadIdsKey, + jsonEncode(settings.favoriteThreadIds), + ); + await prefs.setString( + _threadDownloadDirectoriesKey, + jsonEncode(settings.threadDownloadDirectories), + ); + await prefs.setString( + _automationSnapshotsKey, + jsonEncode(settings.automationSnapshots), + ); + await prefs.setString( + _automationsKey, + jsonEncode( + settings.automations + .map((automation) => automation.toJson()) + .toList(growable: false), + ), + ); + } + + Map _readThreadDownloadDirectories(SharedPreferences prefs) { + final raw = prefs.getString(_threadDownloadDirectoriesKey); + if (raw == null || raw.trim().isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return {}; + } + return decoded.map((key, value) { + return MapEntry(key, value?.toString() ?? ''); + })..removeWhere( + (key, value) => key.trim().isEmpty || value.trim().isEmpty, + ); + } catch (_) { + return {}; + } + } + + List _readFavoriteThreadIds(SharedPreferences prefs) { + final raw = prefs.getString(_favoriteThreadIdsKey); + if (raw == null || raw.trim().isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return const []; + } + return decoded + .map((item) => item?.toString() ?? '') + .where((item) => item.trim().isNotEmpty) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + Map> _readAutomationSnapshots( + SharedPreferences prefs, + ) { + final raw = prefs.getString(_automationSnapshotsKey); + if (raw == null || raw.trim().isEmpty) { + return >{}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return >{}; + } + final result = >{}; + for (final entry in decoded.entries) { + if (entry.key.trim().isEmpty || entry.value is! Map) { + continue; + } + final snapshotMap = {}; + for (final snapshotEntry + in (entry.value as Map).entries) { + final key = snapshotEntry.key.trim(); + final value = snapshotEntry.value?.toString() ?? ''; + if (key.isEmpty || value.trim().isEmpty) { + continue; + } + snapshotMap[key] = value; + } + result[entry.key] = snapshotMap; + } + return result; + } catch (_) { + return >{}; + } + } + + Future> loadRecentCommands() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_recentCommandsKey) ?? const []; + final items = []; + for (final encoded in raw) { + try { + final decoded = jsonDecode(encoded); + if (decoded is! Map) { + continue; + } + items.add( + RecentCommand( + commandText: decoded['commandText']?.toString() ?? '', + cwd: decoded['cwd']?.toString() ?? '', + mode: CommandSessionMode.values.byName( + decoded['mode']?.toString() ?? CommandSessionMode.buffered.name, + ), + sandboxMode: SandboxMode.values.byName( + decoded['sandboxMode']?.toString() ?? + SandboxMode.workspaceWrite.name, + ), + allowNetwork: decoded['allowNetwork'] == true, + disableTimeout: decoded['disableTimeout'] != false, + timeoutMs: decoded['timeoutMs'] as int? ?? 60000, + disableOutputCap: decoded['disableOutputCap'] != false, + outputBytesCap: decoded['outputBytesCap'] as int? ?? 32768, + ), + ); + } catch (_) { + continue; + } + } + return items; + } + + Future saveRecentCommands(List commands) async { + final prefs = await SharedPreferences.getInstance(); + final encoded = commands + .map( + (command) => jsonEncode({ + 'commandText': command.commandText, + 'cwd': command.cwd, + 'mode': command.mode.name, + 'sandboxMode': command.sandboxMode.name, + 'allowNetwork': command.allowNetwork, + 'disableTimeout': command.disableTimeout, + 'timeoutMs': command.timeoutMs, + 'disableOutputCap': command.disableOutputCap, + 'outputBytesCap': command.outputBytesCap, + }), + ) + .toList(); + await prefs.setStringList(_recentCommandsKey, encoded); + } + + List _readAutomations(SharedPreferences prefs) { + final raw = prefs.getString(_automationsKey); + if (raw == null || raw.trim().isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return const []; + } + return decoded + .whereType>() + .map(AutomationDefinition.fromJson) + .where((automation) => automation.id.trim().isNotEmpty) + .toList(growable: false); + } catch (_) { + return const []; + } + } +} + +const _connectionModeKey = 'connection_mode'; +const _serverUrlKey = 'server_url'; +const _websocketBearerTokenKey = 'websocket_bearer_token'; +const _relayUrlKey = 'relay_url'; +const _relayDeviceIdKey = 'relay_device_id'; +const _relayBridgeLabelKey = 'relay_bridge_label'; +const _relayBridgeSigningPublicKeyKey = 'relay_bridge_signing_public_key'; +const _relayClientPrivateKeyKey = 'relay_client_private_key'; +const _relayClientPublicKeyKey = 'relay_client_public_key'; +const _cwdKey = 'cwd'; +const _modelKey = 'model'; +const _reasoningEffortKey = 'reasoning_effort'; +const _planModeKey = 'plan_mode'; +const _approvalPolicyKey = 'approval_policy'; +const _sandboxModeKey = 'sandbox_mode'; +const _allowNetworkKey = 'allow_network'; +const _themePreferenceKey = 'theme_preference'; +const _threadLoadTimeoutMsKey = 'thread_load_timeout_ms'; +const _resumeThreadIdKey = 'resume_thread_id'; +const _favoriteThreadIdsKey = 'favorite_thread_ids'; +const _threadDownloadDirectoriesKey = 'thread_download_directories'; +const _automationSnapshotsKey = 'automation_snapshots'; +const _automationsKey = 'automations'; +const _recentCommandsKey = 'recent_commands'; diff --git a/lib/src/core/infrastructure/transport.dart b/lib/src/core/infrastructure/transport.dart new file mode 100644 index 0000000..7dcb97e --- /dev/null +++ b/lib/src/core/infrastructure/transport.dart @@ -0,0 +1,1142 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:web_socket_channel/io.dart'; + +import '../../models.dart'; + +const String _androidEventChannelName = 'codex_remote/android_events'; +const String _androidMethodChannelName = 'codex_remote/android_transport'; +const int _androidInlinePayloadLimitBytes = 256 * 1024; + +abstract class AppTransport { + Stream get messages; + bool get isConnected; + Future connect(AppSettings settings); + Future disconnect(); + Future send(String payload); +} + +class PlatformAdaptiveTransport implements AppTransport { + final AppTransport _directTransport; + final AppTransport _relayTransport; + final StreamController _messages = + StreamController.broadcast(); + AppTransport? _active; + + PlatformAdaptiveTransport({ + required AppTransport directTransport, + required AppTransport relayTransport, + }) : _directTransport = directTransport, + _relayTransport = relayTransport { + _directTransport.messages.listen( + _messages.add, + onError: _messages.addError, + ); + _relayTransport.messages.listen(_messages.add, onError: _messages.addError); + } + + @override + Stream get messages => _messages.stream; + + @override + bool get isConnected => _active?.isConnected ?? false; + + @override + Future connect(AppSettings settings) async { + final next = settings.connectionMode == ConnectionMode.relay + ? _relayTransport + : _directTransport; + if (_active != null && !identical(_active, next)) { + await _active!.disconnect(); + } + _active = next; + await next.connect(settings); + } + + @override + Future disconnect() async { + await _active?.disconnect(); + } + + @override + Future send(String payload) async { + await _active?.send(payload); + } +} + +class DirectWebSocketTransport implements AppTransport { + final StreamController _messages = + StreamController.broadcast(); + IOWebSocketChannel? _channel; + StreamSubscription? _subscription; + bool _connected = false; + + @override + Stream get messages => _messages.stream; + + @override + bool get isConnected => _connected; + + void _notifyUnexpectedDisconnect() { + _messages.addError(StateError('Transport disconnected.')); + } + + @override + Future connect(AppSettings settings) async { + await disconnect(); + final authToken = settings.websocketBearerToken.trim(); + final socket = await WebSocket.connect( + settings.serverUrl, + headers: authToken.isEmpty + ? null + : {'Authorization': 'Bearer $authToken'}, + ); + socket.pingInterval = const Duration(seconds: 20); + _channel = IOWebSocketChannel(socket); + _subscription = _channel!.stream.listen( + (dynamic event) { + if (event is String) { + _messages.add(event); + } + }, + onError: _messages.addError, + onDone: () { + final wasConnected = _connected; + _connected = false; + if (wasConnected) { + _notifyUnexpectedDisconnect(); + } + }, + ); + _connected = true; + } + + @override + Future disconnect() async { + _connected = false; + await _subscription?.cancel(); + _subscription = null; + await _channel?.sink.close(); + _channel = null; + } + + @override + Future send(String payload) async { + _channel?.sink.add(payload); + } +} + +class AndroidForegroundTransport implements AppTransport { + AndroidForegroundTransport() + : _events = const EventChannel(_androidEventChannelName), + _methods = const MethodChannel(_androidMethodChannelName); + + final EventChannel _events; + final MethodChannel _methods; + final StreamController _messages = + StreamController.broadcast(); + StreamSubscription? _subscription; + Completer? _readyCompleter; + bool _connected = false; + + @override + Stream get messages => _messages.stream; + + @override + bool get isConnected => _connected; + + void _notifyUnexpectedDisconnect() { + _messages.addError(StateError('Transport disconnected.')); + } + + @override + Future connect(AppSettings settings) async { + await _subscription?.cancel(); + _connected = false; + final readyCompleter = Completer(); + _readyCompleter = readyCompleter; + _subscription = _events.receiveBroadcastStream().listen( + (dynamic event) { + if (event is! String) { + return; + } + _handleTransportEvent(event, readyCompleter); + _messages.add(event); + }, + onError: (Object error, StackTrace stackTrace) { + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(error, stackTrace); + } + _messages.addError(error, stackTrace); + }, + onDone: () { + final wasConnected = _connected; + _connected = false; + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(StateError('Transport disconnected.')); + } else if (wasConnected) { + _notifyUnexpectedDisconnect(); + } + }, + ); + final authToken = settings.websocketBearerToken.trim(); + await _methods.invokeMethod('connect', { + 'url': settings.serverUrl, + 'bearerToken': authToken, + }); + await readyCompleter.future.timeout(const Duration(seconds: 15)); + } + + @override + Future disconnect() async { + final readyCompleter = _readyCompleter; + if (readyCompleter != null && !readyCompleter.isCompleted) { + readyCompleter.completeError(StateError('Transport disconnected.')); + } + _readyCompleter = null; + await _methods.invokeMethod('disconnect'); + _connected = false; + } + + @override + Future send(String payload) async { + final readyCompleter = _readyCompleter; + if (readyCompleter == null) { + throw StateError('Android foreground transport is not connected.'); + } + await readyCompleter.future; + await _sendAndroidPayload(_methods, payload); + } + + void _handleTransportEvent(String event, Completer readyCompleter) { + dynamic decoded; + try { + decoded = jsonDecode(event); + } catch (_) { + return; + } + if (decoded is! Map) { + return; + } + if (decoded['method'] != 'android/transportStatus') { + return; + } + final params = decoded['params']; + if (params is! Map) { + return; + } + final status = params['status']?.toString(); + switch (status) { + case 'connected': + _connected = true; + if (!readyCompleter.isCompleted) { + readyCompleter.complete(); + } + break; + case 'disconnected': + _connected = false; + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(StateError('Transport disconnected.')); + } + break; + case 'error': + _connected = false; + if (!readyCompleter.isCompleted) { + readyCompleter.completeError( + StateError( + params['message']?.toString() ?? 'Android transport error.', + ), + ); + } + break; + } + } +} + +class AndroidRelaySecureTransport implements AppTransport { + AndroidRelaySecureTransport() + : _events = const EventChannel(_androidEventChannelName), + _methods = const MethodChannel(_androidMethodChannelName); + + final EventChannel _events; + final MethodChannel _methods; + final StreamController _messages = + StreamController.broadcast(); + final Ed25519 _signing = Ed25519(); + final X25519 _keyAgreement = X25519(); + final Cipher _cipher = Chacha20.poly1305Aead(); + StreamSubscription? _subscription; + Completer? _readyCompleter; + bool _connected = false; + AppSettings? _settings; + KeyPair? _sessionKeyPair; + SecretKey? _sessionSecretKey; + String? _sessionId; + String? _sessionNonce; + int _sendCounter = 0; + int _receiveCounter = 0; + + @override + Stream get messages => _messages.stream; + + @override + bool get isConnected => _connected; + + void _notifyUnexpectedDisconnect() { + _messages.addError(StateError('Transport disconnected.')); + } + + @override + Future connect(AppSettings settings) async { + await disconnect(); + if (settings.relayUrl.trim().isEmpty || + settings.relayDeviceId.trim().isEmpty || + settings.relayClientPrivateKey.trim().isEmpty || + settings.relayClientPublicKey.trim().isEmpty || + settings.relayBridgeSigningPublicKey.trim().isEmpty) { + throw StateError('Relay mode is selected, but relay pairing is missing.'); + } + final relayUri = Uri.tryParse(settings.relayUrl); + if (relayUri == null || !relayUri.hasScheme || relayUri.host.isEmpty) { + throw StateError('Relay URL is invalid.'); + } + if (!_isAllowedRelayUri(relayUri)) { + throw StateError( + 'Relay mode requires HTTPS for non-local relay servers.', + ); + } + _settings = settings; + final readyCompleter = Completer(); + _readyCompleter = readyCompleter; + _subscription = _events.receiveBroadcastStream().listen( + (dynamic event) { + if (event is! String) { + return; + } + if (_handleTransportEvent(event, readyCompleter)) { + return; + } + unawaited(_handleRelayMessage(event)); + }, + onError: (Object error, StackTrace stackTrace) { + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(error, stackTrace); + } else { + _messages.addError(error, stackTrace); + } + }, + onDone: () { + final wasConnected = _connected; + _connected = false; + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(StateError('Transport disconnected.')); + } else if (wasConnected) { + _notifyUnexpectedDisconnect(); + } + }, + ); + await _methods.invokeMethod('connect', { + 'url': relayWebSocketUri(relayUri).toString(), + 'bearerToken': null, + }); + await readyCompleter.future.timeout(const Duration(seconds: 20)); + _connected = true; + } + + @override + Future disconnect() async { + _connected = false; + _sendCounter = 0; + _receiveCounter = 0; + _sessionId = null; + _sessionNonce = null; + _sessionKeyPair = null; + _sessionSecretKey = null; + _settings = null; + final ready = _readyCompleter; + if (ready != null && !ready.isCompleted) { + ready.completeError(StateError('Transport disconnected.')); + } + _readyCompleter = null; + await _subscription?.cancel(); + _subscription = null; + await _methods.invokeMethod('disconnect'); + } + + @override + Future send(String payload) async { + final ready = _readyCompleter; + if (ready == null) { + throw StateError('Relay transport is not connected.'); + } + await ready.future; + final secretKey = _sessionSecretKey; + final sessionId = _sessionId; + final settings = _settings; + if (secretKey == null || sessionId == null || settings == null) { + throw StateError('Relay session is not ready.'); + } + final counter = _sendCounter++; + final aad = _relayAad( + counter: counter, + deviceId: settings.relayDeviceId, + sessionId: sessionId, + ); + final secretBox = await _cipher.encrypt( + utf8.encode(payload), + secretKey: secretKey, + nonce: _nonceFor(prefix: 'CLNT', counter: counter), + aad: aad, + ); + final combined = Uint8List.fromList([ + ...secretBox.cipherText, + ...secretBox.mac.bytes, + ]); + await _sendRaw( + jsonEncode({ + 'counter': counter, + 'ciphertext': _b64urlEncode(combined), + 'sessionId': sessionId, + 'type': 'relay_frame', + }), + ); + } + + bool _handleTransportEvent(String event, Completer readyCompleter) { + dynamic decoded; + try { + decoded = jsonDecode(event); + } catch (_) { + return false; + } + if (decoded is! Map) { + return false; + } + if (decoded['method'] != 'android/transportStatus') { + return false; + } + final params = decoded['params']; + if (params is! Map) { + return true; + } + final status = params['status']?.toString(); + switch (status) { + case 'connected': + break; + case 'disconnected': + _connected = false; + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(StateError('Transport disconnected.')); + } else { + _messages.addError(StateError('Transport disconnected.')); + } + break; + case 'error': + _connected = false; + final message = + params['message']?.toString() ?? 'Android transport error.'; + if (!readyCompleter.isCompleted) { + readyCompleter.completeError(StateError(message)); + } else { + _messages.addError(StateError(message)); + } + break; + } + return true; + } + + Future _sendRaw(String payload) async { + await _sendAndroidPayload(_methods, payload); + } + + Future _handleRelayMessage(String event) async { + final payload = jsonDecode(event) as Map; + switch (payload['type']) { + case 'challenge': + await _respondToChallenge(payload); + case 'authenticated': + break; + case 'session_open': + await _completeSession(payload); + case 'relay_frame': + await _handleEncryptedFrame(payload); + case 'close_session': + final sessionId = payload['sessionId']?.toString(); + if (sessionId == null || sessionId == _sessionId) { + _messages.addError(StateError('Relay session closed by peer.')); + } + default: + break; + } + } + + Future _respondToChallenge(Map payload) async { + final settings = _settings; + if (settings == null) { + return; + } + final authNonce = _randomToken(12); + final authTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final sessionKeyPair = await _keyAgreement.newKeyPair(); + final sessionKeyPairData = await sessionKeyPair.extract(); + final sessionPublicKey = await sessionKeyPair.extractPublicKey(); + final sessionNonce = _randomToken(12); + final signedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final signingKeyPair = _clientSigningKeyPair(settings); + final authSignature = await _signing.sign( + _canonicalJson({ + 'authNonce': authNonce, + 'authTimestamp': authTimestamp, + 'challenge': payload['challenge'], + 'connectionId': payload['connectionId'], + 'deviceId': settings.relayDeviceId, + 'role': 'client', + 'type': 'codex-remote-auth-v1', + }), + keyPair: signingKeyPair, + ); + final sessionSignature = await _signing.sign( + _canonicalJson({ + 'deviceId': settings.relayDeviceId, + 'role': 'client', + 'sessionNonce': sessionNonce, + 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), + 'signedAt': signedAt, + 'type': 'codex-remote-session-bundle-v1', + }), + keyPair: signingKeyPair, + ); + _sessionKeyPair = sessionKeyPairData; + _sessionNonce = sessionNonce; + await _sendRaw( + jsonEncode({ + 'authNonce': authNonce, + 'authSignature': _b64urlEncode(authSignature.bytes), + 'authTimestamp': authTimestamp, + 'deviceId': settings.relayDeviceId, + 'role': 'client', + 'sessionBundle': { + 'sessionNonce': sessionNonce, + 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), + 'signature': _b64urlEncode(sessionSignature.bytes), + 'signedAt': signedAt, + }, + 'type': 'authenticate', + }), + ); + } + + Future _completeSession(Map payload) async { + final settings = _settings; + final sessionKeyPair = _sessionKeyPair; + final sessionNonce = _sessionNonce; + if (settings == null || sessionKeyPair == null || sessionNonce == null) { + return; + } + final peerSigningKey = payload['peerSigningPublicKey']?.toString() ?? ''; + if (peerSigningKey != settings.relayBridgeSigningPublicKey) { + throw StateError( + 'Relay bridge identity does not match the paired bridge.', + ); + } + final bundle = payload['peerSessionBundle'] as Map; + final peerSessionPublicKey = bundle['sessionPublicKey']?.toString() ?? ''; + final peerSessionNonce = bundle['sessionNonce']?.toString() ?? ''; + final signatureBytes = _b64urlDecode(bundle['signature']?.toString() ?? ''); + final verified = await _signing.verify( + _canonicalJson({ + 'deviceId': settings.relayDeviceId, + 'role': 'bridge', + 'sessionNonce': peerSessionNonce, + 'sessionPublicKey': peerSessionPublicKey, + 'signedAt': bundle['signedAt'], + 'type': 'codex-remote-session-bundle-v1', + }), + signature: Signature( + signatureBytes, + publicKey: SimplePublicKey( + _b64urlDecode(peerSigningKey), + type: KeyPairType.ed25519, + ), + ), + ); + if (!verified) { + throw StateError('Bridge session signature verification failed.'); + } + final sharedSecret = await _keyAgreement.sharedSecretKey( + keyPair: sessionKeyPair, + remotePublicKey: SimplePublicKey( + _b64urlDecode(peerSessionPublicKey), + type: KeyPairType.x25519, + ), + ); + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + final salt = await deriveRelaySaltBytes( + localNonce: sessionNonce, + peerNonce: peerSessionNonce, + ); + _sessionSecretKey = await hkdf.deriveKey( + secretKey: sharedSecret, + nonce: salt, + info: utf8.encode(settings.relayDeviceId), + ); + final sessionId = payload['sessionId']?.toString(); + if (sessionId == null || sessionId.isEmpty) { + throw StateError('Relay did not provide a session identifier.'); + } + _sessionId = sessionId; + _sendCounter = 0; + _receiveCounter = 0; + if (!(_readyCompleter?.isCompleted ?? true)) { + _readyCompleter?.complete(); + } + } + + Future _handleEncryptedFrame(Map payload) async { + final secretKey = _sessionSecretKey; + final sessionId = _sessionId; + final settings = _settings; + if (secretKey == null || sessionId == null || settings == null) { + return; + } + final messageSessionId = payload['sessionId']?.toString(); + if (messageSessionId != sessionId) { + return; + } + final counter = payload['counter'] as int? ?? -1; + if (counter != _receiveCounter) { + throw StateError( + 'Unexpected relay frame counter: expected $_receiveCounter, received $counter.', + ); + } + _receiveCounter += 1; + final combined = _b64urlDecode(payload['ciphertext']?.toString() ?? ''); + if (combined.length < 16) { + throw StateError('Relay ciphertext is truncated.'); + } + final cipherText = combined.sublist(0, combined.length - 16); + final mac = Mac(combined.sublist(combined.length - 16)); + final secretBox = SecretBox( + cipherText, + nonce: _nonceFor(prefix: 'BRDG', counter: counter), + mac: mac, + ); + final plainBytes = await _cipher.decrypt( + secretBox, + secretKey: secretKey, + aad: _relayAad( + counter: counter, + deviceId: settings.relayDeviceId, + sessionId: sessionId, + ), + ); + _messages.add(utf8.decode(plainBytes)); + } + + Uint8List _relayAad({ + required int counter, + required String deviceId, + required String sessionId, + }) { + return Uint8List.fromList( + _canonicalJson({ + 'counter': counter, + 'deviceId': deviceId, + 'sessionId': sessionId, + 'type': 'relay-frame-v1', + }), + ); + } + + SimpleKeyPairData _clientSigningKeyPair(AppSettings settings) { + return SimpleKeyPairData( + _b64urlDecode(settings.relayClientPrivateKey), + publicKey: SimplePublicKey( + _b64urlDecode(settings.relayClientPublicKey), + type: KeyPairType.ed25519, + ), + type: KeyPairType.ed25519, + ); + } +} + +Future _sendAndroidPayload(MethodChannel methods, String payload) async { + final payloadBytes = utf8.encode(payload); + if (payloadBytes.length <= _androidInlinePayloadLimitBytes) { + await methods.invokeMethod('send', { + 'payload': payload, + }); + return; + } + + final file = await _writeAndroidTransportPayload(payload); + await methods.invokeMethod('sendFile', { + 'path': file.path, + }); +} + +Future _writeAndroidTransportPayload(String payload) async { + final directory = await Directory( + '${Directory.systemTemp.path}${Platform.pathSeparator}codex_remote_payloads', + ).create(recursive: true); + final file = File( + '${directory.path}${Platform.pathSeparator}' + 'payload-${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}.json', + ); + await file.writeAsString(payload, flush: true); + return file; +} + +class RelaySecureTransport implements AppTransport { + final StreamController _messages = + StreamController.broadcast(); + final Ed25519 _signing = Ed25519(); + final X25519 _keyAgreement = X25519(); + final Cipher _cipher = Chacha20.poly1305Aead(); + IOWebSocketChannel? _channel; + StreamSubscription? _subscription; + Completer? _readyCompleter; + bool _connected = false; + AppSettings? _settings; + KeyPair? _sessionKeyPair; + SecretKey? _sessionSecretKey; + String? _sessionId; + String? _sessionNonce; + int _sendCounter = 0; + int _receiveCounter = 0; + + @override + Stream get messages => _messages.stream; + + @override + bool get isConnected => _connected; + + void _notifyUnexpectedDisconnect() { + _messages.addError(StateError('Transport disconnected.')); + } + + @override + Future connect(AppSettings settings) async { + await disconnect(); + if (settings.relayUrl.trim().isEmpty || + settings.relayDeviceId.trim().isEmpty || + settings.relayClientPrivateKey.trim().isEmpty || + settings.relayClientPublicKey.trim().isEmpty || + settings.relayBridgeSigningPublicKey.trim().isEmpty) { + throw StateError('Relay mode is selected, but relay pairing is missing.'); + } + final relayUri = Uri.tryParse(settings.relayUrl); + if (relayUri == null || !relayUri.hasScheme || relayUri.host.isEmpty) { + throw StateError('Relay URL is invalid.'); + } + if (!_isAllowedRelayUri(relayUri)) { + throw StateError( + 'Relay mode requires HTTPS for non-local relay servers.', + ); + } + _settings = settings; + _readyCompleter = Completer(); + _channel = IOWebSocketChannel.connect( + relayWebSocketUri(relayUri), + pingInterval: const Duration(seconds: 20), + connectTimeout: const Duration(seconds: 15), + ); + _subscription = _channel!.stream.listen( + _handleRelayMessage, + onError: (Object error, StackTrace stackTrace) { + if (!(_readyCompleter?.isCompleted ?? true)) { + _readyCompleter?.completeError(error, stackTrace); + } else { + _messages.addError(error, stackTrace); + } + }, + onDone: () { + final wasConnected = _connected; + _connected = false; + if (wasConnected) { + _notifyUnexpectedDisconnect(); + } + }, + ); + await _readyCompleter!.future.timeout(const Duration(seconds: 20)); + _connected = true; + } + + @override + Future disconnect() async { + _connected = false; + _sendCounter = 0; + _receiveCounter = 0; + _sessionId = null; + _sessionNonce = null; + _sessionKeyPair = null; + _sessionSecretKey = null; + _settings = null; + final ready = _readyCompleter; + if (ready != null && !ready.isCompleted) { + ready.completeError(StateError('Transport disconnected.')); + } + _readyCompleter = null; + await _subscription?.cancel(); + _subscription = null; + await _channel?.sink.close(); + _channel = null; + } + + @override + Future send(String payload) async { + final ready = _readyCompleter; + if (ready == null) { + throw StateError('Relay transport is not connected.'); + } + await ready.future; + final secretKey = _sessionSecretKey; + final sessionId = _sessionId; + final settings = _settings; + if (secretKey == null || sessionId == null || settings == null) { + throw StateError('Relay session is not ready.'); + } + final counter = _sendCounter++; + final aad = _relayAad( + counter: counter, + deviceId: settings.relayDeviceId, + sessionId: sessionId, + ); + final secretBox = await _cipher.encrypt( + utf8.encode(payload), + secretKey: secretKey, + nonce: _nonceFor(prefix: 'CLNT', counter: counter), + aad: aad, + ); + final combined = Uint8List.fromList([ + ...secretBox.cipherText, + ...secretBox.mac.bytes, + ]); + _channel?.sink.add( + jsonEncode({ + 'counter': counter, + 'ciphertext': _b64urlEncode(combined), + 'sessionId': sessionId, + 'type': 'relay_frame', + }), + ); + } + + Future _handleRelayMessage(dynamic event) async { + if (event is! String) { + return; + } + final payload = jsonDecode(event) as Map; + switch (payload['type']) { + case 'challenge': + await _respondToChallenge(payload); + case 'authenticated': + break; + case 'session_open': + await _completeSession(payload); + case 'relay_frame': + await _handleEncryptedFrame(payload); + case 'close_session': + final sessionId = payload['sessionId']?.toString(); + if (sessionId == null || sessionId == _sessionId) { + _messages.addError(StateError('Relay session closed by peer.')); + } + default: + break; + } + } + + Future _respondToChallenge(Map payload) async { + final settings = _settings; + if (settings == null) { + return; + } + final authNonce = _randomToken(12); + final authTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final sessionKeyPair = await _keyAgreement.newKeyPair(); + final sessionKeyPairData = await sessionKeyPair.extract(); + final sessionPublicKey = await sessionKeyPair.extractPublicKey(); + final sessionNonce = _randomToken(12); + final signedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final signingKeyPair = _clientSigningKeyPair(settings); + final authSignature = await _signing.sign( + _canonicalJson({ + 'authNonce': authNonce, + 'authTimestamp': authTimestamp, + 'challenge': payload['challenge'], + 'connectionId': payload['connectionId'], + 'deviceId': settings.relayDeviceId, + 'role': 'client', + 'type': 'codex-remote-auth-v1', + }), + keyPair: signingKeyPair, + ); + final sessionSignature = await _signing.sign( + _canonicalJson({ + 'deviceId': settings.relayDeviceId, + 'role': 'client', + 'sessionNonce': sessionNonce, + 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), + 'signedAt': signedAt, + 'type': 'codex-remote-session-bundle-v1', + }), + keyPair: signingKeyPair, + ); + _sessionKeyPair = sessionKeyPairData; + _sessionNonce = sessionNonce; + _channel?.sink.add( + jsonEncode({ + 'authNonce': authNonce, + 'authSignature': _b64urlEncode(authSignature.bytes), + 'authTimestamp': authTimestamp, + 'deviceId': settings.relayDeviceId, + 'role': 'client', + 'sessionBundle': { + 'sessionNonce': sessionNonce, + 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), + 'signature': _b64urlEncode(sessionSignature.bytes), + 'signedAt': signedAt, + }, + 'type': 'authenticate', + }), + ); + } + + Future _completeSession(Map payload) async { + final settings = _settings; + final sessionKeyPair = _sessionKeyPair; + final sessionNonce = _sessionNonce; + if (settings == null || sessionKeyPair == null || sessionNonce == null) { + return; + } + final peerSigningKey = payload['peerSigningPublicKey']?.toString() ?? ''; + if (peerSigningKey != settings.relayBridgeSigningPublicKey) { + throw StateError( + 'Relay bridge identity does not match the paired bridge.', + ); + } + final bundle = payload['peerSessionBundle'] as Map; + final peerSessionPublicKey = bundle['sessionPublicKey']?.toString() ?? ''; + final peerSessionNonce = bundle['sessionNonce']?.toString() ?? ''; + final signatureBytes = _b64urlDecode(bundle['signature']?.toString() ?? ''); + final verified = await _signing.verify( + _canonicalJson({ + 'deviceId': settings.relayDeviceId, + 'role': 'bridge', + 'sessionNonce': peerSessionNonce, + 'sessionPublicKey': peerSessionPublicKey, + 'signedAt': bundle['signedAt'], + 'type': 'codex-remote-session-bundle-v1', + }), + signature: Signature( + signatureBytes, + publicKey: SimplePublicKey( + _b64urlDecode(peerSigningKey), + type: KeyPairType.ed25519, + ), + ), + ); + if (!verified) { + throw StateError('Bridge session signature verification failed.'); + } + final sharedSecret = await _keyAgreement.sharedSecretKey( + keyPair: sessionKeyPair, + remotePublicKey: SimplePublicKey( + _b64urlDecode(peerSessionPublicKey), + type: KeyPairType.x25519, + ), + ); + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + final salt = await deriveRelaySaltBytes( + localNonce: sessionNonce, + peerNonce: peerSessionNonce, + ); + _sessionSecretKey = await hkdf.deriveKey( + secretKey: sharedSecret, + nonce: salt, + info: utf8.encode(settings.relayDeviceId), + ); + final sessionId = payload['sessionId']?.toString(); + if (sessionId == null || sessionId.isEmpty) { + throw StateError('Relay did not provide a session identifier.'); + } + _sessionId = sessionId; + _sendCounter = 0; + _receiveCounter = 0; + if (!(_readyCompleter?.isCompleted ?? true)) { + _readyCompleter?.complete(); + } + } + + Future _handleEncryptedFrame(Map payload) async { + final secretKey = _sessionSecretKey; + final sessionId = _sessionId; + final settings = _settings; + if (secretKey == null || sessionId == null || settings == null) { + return; + } + final messageSessionId = payload['sessionId']?.toString(); + if (messageSessionId != sessionId) { + return; + } + final counter = payload['counter'] as int? ?? -1; + if (counter != _receiveCounter) { + throw StateError( + 'Unexpected relay frame counter: expected $_receiveCounter, received $counter.', + ); + } + _receiveCounter += 1; + final combined = _b64urlDecode(payload['ciphertext']?.toString() ?? ''); + if (combined.length < 16) { + throw StateError('Relay ciphertext is truncated.'); + } + final cipherText = combined.sublist(0, combined.length - 16); + final mac = Mac(combined.sublist(combined.length - 16)); + final secretBox = SecretBox( + cipherText, + nonce: _nonceFor(prefix: 'BRDG', counter: counter), + mac: mac, + ); + final plainBytes = await _cipher.decrypt( + secretBox, + secretKey: secretKey, + aad: _relayAad( + counter: counter, + deviceId: settings.relayDeviceId, + sessionId: sessionId, + ), + ); + _messages.add(utf8.decode(plainBytes)); + } + + Uint8List _relayAad({ + required int counter, + required String deviceId, + required String sessionId, + }) { + return Uint8List.fromList( + _canonicalJson({ + 'counter': counter, + 'deviceId': deviceId, + 'sessionId': sessionId, + 'type': 'relay-frame-v1', + }), + ); + } + + SimpleKeyPairData _clientSigningKeyPair(AppSettings settings) { + return SimpleKeyPairData( + _b64urlDecode(settings.relayClientPrivateKey), + publicKey: SimplePublicKey( + _b64urlDecode(settings.relayClientPublicKey), + type: KeyPairType.ed25519, + ), + type: KeyPairType.ed25519, + ); + } +} + +AppTransport createDefaultTransport() { + final directTransport = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android + ? AndroidForegroundTransport() + : DirectWebSocketTransport(); + final relayTransport = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android + ? AndroidRelaySecureTransport() + : RelaySecureTransport(); + return PlatformAdaptiveTransport( + directTransport: directTransport, + relayTransport: relayTransport, + ); +} + +bool _isAllowedRelayUri(Uri relayUri) { + if (relayUri.scheme == 'https') { + return true; + } + if (relayUri.scheme != 'http') { + return false; + } + final host = relayUri.host.toLowerCase(); + return host == 'localhost' || + host == '127.0.0.1' || + host == '::1' || + host.endsWith('.local'); +} + +@visibleForTesting +Uri relayWebSocketUri(Uri relayUri) { + final wsScheme = switch (relayUri.scheme) { + 'https' => 'wss', + 'http' => 'ws', + _ => relayUri.scheme, + }; + final normalizedPath = relayUri.path.endsWith('/') + ? '${relayUri.path}ws' + : '${relayUri.path}/ws'; + return relayUri.replace(scheme: wsScheme, path: normalizedPath); +} + +@visibleForTesting +Future> deriveRelaySaltBytes({ + required String localNonce, + required String peerNonce, +}) async { + final sorted = [localNonce, peerNonce]..sort(); + final digest = await Sha256().hash(utf8.encode(sorted.join())); + return digest.bytes; +} + +String _b64urlEncode(List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); +} + +Uint8List _b64urlDecode(String value) { + final normalized = value.padRight((value.length + 3) ~/ 4 * 4, '='); + return Uint8List.fromList(base64Url.decode(normalized)); +} + +Uint8List _canonicalJson(Map payload) { + return Uint8List.fromList(utf8.encode(jsonEncode(_sortJson(payload)))); +} + +Object _sortJson(Object value) { + if (value is Map) { + final entries = value.entries.toList() + ..sort((MapEntry a, MapEntry b) { + return a.key.compareTo(b.key); + }); + return Map.fromEntries( + entries.map((entry) => MapEntry(entry.key, _sortJson(entry.value))), + ); + } + if (value is List) { + return value.map((dynamic item) => _sortJson(item)).toList(growable: false); + } + return value; +} + +String _randomToken(int length) { + final random = Random.secure(); + final seed = Uint8List.fromList( + List.generate(length, (_) => random.nextInt(256)), + ); + return _b64urlEncode(seed); +} + +Uint8List _nonceFor({required String prefix, required int counter}) { + final bytes = ByteData(12); + final prefixBytes = ascii.encode(prefix); + for (var i = 0; i < 4; i += 1) { + bytes.setUint8(i, prefixBytes[i]); + } + bytes.setUint64(4, counter); + return bytes.buffer.asUint8List(); +} diff --git a/lib/src/core/platform/download_location_opener.dart b/lib/src/core/platform/download_location_opener.dart new file mode 100644 index 0000000..6b83784 --- /dev/null +++ b/lib/src/core/platform/download_location_opener.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:permission_handler/permission_handler.dart'; + +Future openDownloadedLocation( + BuildContext context, + String savedPath, +) async { + if (Platform.isAndroid) { + final currentStatus = await Permission.manageExternalStorage.status; + if (!currentStatus.isGranted) { + final requested = await Permission.manageExternalStorage.request(); + if (!requested.isGranted) { + await openAppSettings(); + if (!context.mounted) { + return; + } + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + 'Allow All files access for Codex Remote to open downloaded locations.', + ), + ), + ); + return; + } + } + } + final parentPath = File(savedPath).parent.path; + var result = await OpenFilex.open(parentPath); + if (result.type != ResultType.done) { + result = await OpenFilex.open(savedPath); + } + if (result.type == ResultType.done || !context.mounted) { + return; + } + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + result.message.isNotEmpty + ? result.message + : 'Unable to open the downloaded file location.', + ), + ), + ); +} diff --git a/lib/src/core/widgets/monospace_output_view.dart b/lib/src/core/widgets/monospace_output_view.dart new file mode 100644 index 0000000..06e8a98 --- /dev/null +++ b/lib/src/core/widgets/monospace_output_view.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +class MonospaceOutputView extends StatelessWidget { + const MonospaceOutputView({ + super.key, + required this.text, + required this.style, + this.scrollable = true, + }); + + final String text; + final TextStyle? style; + final bool scrollable; + + @override + Widget build(BuildContext context) { + final displayText = _repairDisplayText(text); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final lineLengths = displayText.split('\n').map((line) => line.length); + final longestLineLength = lineLengths.isEmpty + ? 0 + : lineLengths.reduce((left, right) => left > right ? left : right); + final fontSize = style?.fontSize ?? 12; + final estimatedCharWidth = fontSize * 0.62; + final contentWidth = (longestLineLength * estimatedCharWidth) + 24; + final targetWidth = + constraints.hasBoundedWidth && contentWidth < constraints.maxWidth + ? constraints.maxWidth + : contentWidth; + final content = SizedBox( + width: targetWidth, + child: SelectionArea( + child: Text( + displayText, + overflow: TextOverflow.visible, + softWrap: false, + textWidthBasis: TextWidthBasis.longestLine, + strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.2), + style: style, + ), + ), + ); + if (!scrollable) { + return content; + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView(child: content), + ); + }, + ); + } + + String _repairDisplayText(String value) { + final lines = value.split('\n'); + if (lines.length < 8) { + return value; + } + + final repaired = []; + final run = []; + + void flushRun() { + if (run.isEmpty) { + return; + } + final nonEmpty = run.where((line) => line.isNotEmpty).toList(); + final mostlySingleChar = + nonEmpty.length >= 6 && + nonEmpty.every((line) { + final trimmed = line.trim(); + return line.runes.length == 1 || trimmed.runes.length == 1; + }); + if (mostlySingleChar) { + final joined = run.join(); + if (repaired.isNotEmpty && joined.startsWith(RegExp(r'\s'))) { + repaired[repaired.length - 1] = '${repaired.last}$joined'; + } else { + repaired.add(joined); + } + } else { + repaired.addAll(run); + } + run.clear(); + } + + for (final line in lines) { + final trimmed = line.trim(); + final isRepairableSingleChar = + line.isEmpty || line.runes.length == 1 || trimmed.runes.length == 1; + if (isRepairableSingleChar) { + run.add(line); + } else { + flushRun(); + repaired.add(line); + } + } + flushRun(); + + return repaired.join('\n'); + } +} diff --git a/lib/src/features/automations/domain/automation_models.dart b/lib/src/features/automations/domain/automation_models.dart new file mode 100644 index 0000000..1f522da --- /dev/null +++ b/lib/src/features/automations/domain/automation_models.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; + +enum AutomationNodeKind { + watchFileChanged, + watchDirectoryChanged, + turnCompleted, + didPathChangeSinceLastRun, + ifElse, + quit, + downloadChangedFile, + installDownloadedApk, + sendMessageToCurrentThread, + runCommand, +} + +enum AutomationBranchOutcome { continueFlow, quitFlow } + +extension AutomationNodeKindUi on AutomationNodeKind { + bool get isTrigger { + return this == AutomationNodeKind.watchFileChanged || + this == AutomationNodeKind.watchDirectoryChanged || + this == AutomationNodeKind.turnCompleted; + } + + String get title { + return switch (this) { + AutomationNodeKind.watchFileChanged => 'Watch file changes', + AutomationNodeKind.watchDirectoryChanged => 'Watch folder changes', + AutomationNodeKind.turnCompleted => 'Turn completed', + AutomationNodeKind.didPathChangeSinceLastRun => + 'Did file or folder change', + AutomationNodeKind.ifElse => 'If / else', + AutomationNodeKind.quit => 'Quit', + AutomationNodeKind.downloadChangedFile => 'Download changed file', + AutomationNodeKind.installDownloadedApk => 'Install downloaded APK', + AutomationNodeKind.sendMessageToCurrentThread => + 'Send message to current thread', + AutomationNodeKind.runCommand => 'Run command', + }; + } + + IconData get icon { + return switch (this) { + AutomationNodeKind.watchFileChanged => Icons.description_outlined, + AutomationNodeKind.watchDirectoryChanged => Icons.folder_outlined, + AutomationNodeKind.turnCompleted => Icons.task_alt_outlined, + AutomationNodeKind.didPathChangeSinceLastRun => + Icons.rule_folder_outlined, + AutomationNodeKind.ifElse => Icons.call_split_outlined, + AutomationNodeKind.quit => Icons.stop_circle_outlined, + AutomationNodeKind.downloadChangedFile => Icons.download_outlined, + AutomationNodeKind.installDownloadedApk => Icons.android_outlined, + AutomationNodeKind.sendMessageToCurrentThread => + Icons.mark_chat_unread_outlined, + AutomationNodeKind.runCommand => Icons.terminal_outlined, + }; + } +} + +class AutomationNode { + const AutomationNode({ + required this.id, + required this.kind, + this.path = '', + this.commandText = '', + this.cwd = '', + this.directory = '', + this.conditionToken = '', + this.whenTrue = AutomationBranchOutcome.continueFlow, + this.whenFalse = AutomationBranchOutcome.quitFlow, + }); + + final String id; + final AutomationNodeKind kind; + final String path; + final String commandText; + final String cwd; + final String directory; + final String conditionToken; + final AutomationBranchOutcome whenTrue; + final AutomationBranchOutcome whenFalse; + + AutomationNode copyWith({ + String? id, + AutomationNodeKind? kind, + String? path, + String? commandText, + String? cwd, + String? directory, + String? conditionToken, + AutomationBranchOutcome? whenTrue, + AutomationBranchOutcome? whenFalse, + }) { + return AutomationNode( + id: id ?? this.id, + kind: kind ?? this.kind, + path: path ?? this.path, + commandText: commandText ?? this.commandText, + cwd: cwd ?? this.cwd, + directory: directory ?? this.directory, + conditionToken: conditionToken ?? this.conditionToken, + whenTrue: whenTrue ?? this.whenTrue, + whenFalse: whenFalse ?? this.whenFalse, + ); + } + + Map toJson() { + return { + 'id': id, + 'kind': kind.name, + 'path': path, + 'commandText': commandText, + 'cwd': cwd, + 'directory': directory, + 'conditionToken': conditionToken, + 'whenTrue': whenTrue.name, + 'whenFalse': whenFalse.name, + }; + } + + factory AutomationNode.fromJson(Map json) { + final kindName = json['kind']?.toString() ?? ''; + final kind = AutomationNodeKind.values.firstWhere( + (value) => value.name == kindName, + orElse: () => AutomationNodeKind.runCommand, + ); + return AutomationNode( + id: json['id']?.toString() ?? '', + kind: kind, + path: json['path']?.toString() ?? '', + commandText: json['commandText']?.toString() ?? '', + cwd: json['cwd']?.toString() ?? '', + directory: json['directory']?.toString() ?? '', + conditionToken: json['conditionToken']?.toString() ?? '', + whenTrue: AutomationBranchOutcome.values.firstWhere( + (value) => value.name == json['whenTrue']?.toString(), + orElse: () => AutomationBranchOutcome.continueFlow, + ), + whenFalse: AutomationBranchOutcome.values.firstWhere( + (value) => value.name == json['whenFalse']?.toString(), + orElse: () => AutomationBranchOutcome.quitFlow, + ), + ); + } +} + +class AutomationDefinition { + const AutomationDefinition({ + required this.id, + required this.name, + required this.enabled, + required this.nodes, + this.ownerThreadId = '', + }); + + final String id; + final String name; + final bool enabled; + final List nodes; + final String ownerThreadId; + + AutomationNode? get triggerNode { + for (final node in nodes) { + if (node.kind.isTrigger) { + return node; + } + } + return null; + } + + List get actionNodes { + return nodes.where((node) => !node.kind.isTrigger).toList(growable: false); + } + + AutomationDefinition copyWith({ + String? id, + String? name, + bool? enabled, + List? nodes, + String? ownerThreadId, + }) { + return AutomationDefinition( + id: id ?? this.id, + name: name ?? this.name, + enabled: enabled ?? this.enabled, + nodes: nodes ?? this.nodes, + ownerThreadId: ownerThreadId ?? this.ownerThreadId, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'enabled': enabled, + 'nodes': nodes.map((node) => node.toJson()).toList(), + 'ownerThreadId': ownerThreadId, + }; + } + + factory AutomationDefinition.fromJson(Map json) { + final rawNodes = json['nodes']; + final nodes = []; + if (rawNodes is List) { + for (final item in rawNodes) { + if (item is Map) { + nodes.add(AutomationNode.fromJson(item)); + } + } + } + return AutomationDefinition( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + enabled: json['enabled'] != false, + nodes: nodes, + ownerThreadId: json['ownerThreadId']?.toString() ?? '', + ); + } +} diff --git a/lib/src/features/automations/presentation/automation_pages.dart b/lib/src/features/automations/presentation/automation_pages.dart new file mode 100644 index 0000000..5f26c2a --- /dev/null +++ b/lib/src/features/automations/presentation/automation_pages.dart @@ -0,0 +1,1281 @@ +// ignore_for_file: deprecated_member_use + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../app_controller.dart'; +import '../../../models.dart'; + +class AutomationPage extends StatefulWidget { + const AutomationPage({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _AutomationPageState(); +} + +class _AutomationPageState extends State { + bool _showAllAutomations = false; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (BuildContext context, Widget? child) { + final theme = Theme.of(context); + final visibleAutomations = _showAllAutomations + ? widget.controller.automations + : widget.controller.automations + .where(widget.controller.isAutomationVisibleInCurrentThread) + .toList(growable: false); + final emptyMessage = _showAllAutomations + ? 'Create automations from nodes: a filesystem watch trigger followed by sequential actions like download, install APK, or run a command.' + : 'No automations are scoped to the current thread yet.'; + return Theme( + data: theme.copyWith( + splashFactory: InkRipple.splashFactory, + useMaterial3: false, + ), + child: Scaffold( + appBar: AppBar( + title: const Text('Automations'), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: GestureDetector( + onTap: () { + setState(() { + _showAllAutomations = !_showAllAutomations; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: theme.dividerColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showAllAutomations ? Icons.list : Icons.filter_alt, + size: 18, + ), + const SizedBox(width: 6), + Text(_showAllAutomations ? 'All' : 'Current'), + ], + ), + ), + ), + ), + IconButton( + tooltip: 'New automation', + onPressed: () => _openEditor(context), + icon: const Icon(Icons.add), + ), + ], + ), + body: visibleAutomations.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Text( + emptyMessage, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + itemBuilder: (BuildContext context, int index) { + final automation = visibleAutomations[index]; + final ownerThreadId = automation.ownerThreadId.trim(); + final currentThreadId = widget + .controller + .currentAutomationScopeThreadId + .trim(); + final canCopyToCurrentThread = + ownerThreadId.isNotEmpty && + currentThreadId.isNotEmpty && + ownerThreadId != currentThreadId; + return _AutomationCard( + automation: automation, + isRunning: widget.controller.isAutomationRunning( + automation.id, + ), + onToggleEnabled: (value) { + widget.controller.setAutomationEnabled( + automation.id, + value, + ); + }, + onEdit: () => + _openEditor(context, automation: automation), + onCopyToCurrentThread: canCopyToCurrentThread + ? () => widget.controller + .copyAutomationToCurrentThread(automation.id) + : null, + onDelete: () => + widget.controller.deleteAutomation(automation.id), + ); + }, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemCount: visibleAutomations.length, + ), + ), + ); + }, + ); + } + + Future _openEditor( + BuildContext context, { + AutomationDefinition? automation, + }) async { + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return AutomationEditorPage( + controller: widget.controller, + initialAutomation: + automation ?? + AutomationDefinition( + id: 'automation-${DateTime.now().microsecondsSinceEpoch}', + name: '', + enabled: true, + nodes: const [], + ), + ); + }, + ), + ); + } +} + +class _AutomationCard extends StatelessWidget { + const _AutomationCard({ + required this.automation, + required this.isRunning, + required this.onToggleEnabled, + required this.onEdit, + required this.onCopyToCurrentThread, + required this.onDelete, + }); + + final AutomationDefinition automation; + final bool isRunning; + final ValueChanged onToggleEnabled; + final VoidCallback onEdit; + final VoidCallback? onCopyToCurrentThread; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final trigger = automation.triggerNode; + final actions = automation.actionNodes; + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + automation.name.trim().isEmpty + ? 'Untitled automation' + : automation.name, + style: theme.textTheme.titleMedium, + ), + ), + Switch(value: automation.enabled, onChanged: onToggleEnabled), + ], + ), + const SizedBox(height: 8), + Text( + trigger == null + ? 'No trigger configured' + : trigger.path.trim().isEmpty + ? trigger.kind.title + : '${trigger.kind.title} • ${trigger.path}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 6), + Text( + actions.isEmpty + ? 'No actions configured' + : actions.map((node) => node.kind.title).join(' → '), + style: theme.textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: isRunning + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isRunning + ? 'Running' + : automation.enabled + ? 'Enabled' + : 'Disabled', + style: theme.textTheme.labelSmall?.copyWith( + color: isRunning + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const Spacer(), + _AutomationActionButton(label: 'Edit', onTap: onEdit), + if (onCopyToCurrentThread != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: _AutomationActionButton( + label: 'Copy', + onTap: onCopyToCurrentThread!, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: _AutomationActionButton( + label: 'Delete', + onTap: onDelete, + destructive: true, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _AutomationActionButton extends StatelessWidget { + const _AutomationActionButton({ + required this.label, + required this.onTap, + this.destructive = false, + this.icon, + }); + + final String label; + final VoidCallback onTap; + final bool destructive; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final foreground = destructive ? scheme.error : scheme.primary; + final background = destructive + ? scheme.error.withValues(alpha: 0.10) + : scheme.primary.withValues(alpha: 0.10); + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: foreground), + const SizedBox(width: 6), + ], + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +class _AutomationIconAction extends StatelessWidget { + const _AutomationIconAction({ + required this.tooltip, + required this.icon, + required this.onTap, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor), + ), + child: Icon(icon, size: 20, color: theme.colorScheme.primary), + ), + ), + ); + } +} + +class AutomationEditorPage extends StatefulWidget { + const AutomationEditorPage({ + super.key, + required this.controller, + required this.initialAutomation, + }); + + final AppController controller; + final AutomationDefinition initialAutomation; + + @override + State createState() => _AutomationEditorPageState(); +} + +class _AutomationEditorPageState extends State { + late final TextEditingController _nameController; + late bool _enabled; + late List _nodes; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: widget.initialAutomation.name, + ); + _enabled = widget.initialAutomation.enabled; + _nodes = List.from(widget.initialAutomation.nodes); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final trigger = _nodes.where((node) => node.kind.isTrigger).toList(); + final actions = _nodes.where((node) => !node.kind.isTrigger).toList(); + return Theme( + data: theme.copyWith( + splashFactory: InkRipple.splashFactory, + useMaterial3: false, + ), + child: Scaffold( + appBar: AppBar( + title: const Text('Automation'), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: _AutomationActionButton( + label: 'Save', + onTap: _saveAutomation, + ), + ), + ], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Automation name'), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: _enabled, + onChanged: (value) { + setState(() { + _enabled = value; + }); + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Text('Nodes', style: theme.textTheme.titleLarge), + const Spacer(), + if (trigger.isEmpty) + _AutomationActionButton( + label: 'Add trigger', + icon: Icons.flash_on_outlined, + onTap: () => _addNode(isTrigger: true), + ) + else + _AutomationActionButton( + label: 'Add action', + icon: Icons.add, + onTap: () => _addNode(isTrigger: false), + ), + ], + ), + const SizedBox(height: 12), + if (_nodes.isEmpty) + Text( + 'Start with a trigger, then add sequential action and control nodes.', + style: theme.textTheme.bodyMedium, + ) + else + ..._nodes.asMap().entries.map((entry) { + final index = entry.key; + final node = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index == _nodes.length - 1 ? 0 : 10, + ), + child: _AutomationNodeCard( + index: index, + node: node, + onEdit: () => _editNode(index), + onDelete: () { + setState(() { + _nodes.removeAt(index); + }); + }, + ), + ); + }), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 18), + Text( + 'Sequential actions run in the order shown above.', + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + ), + ); + } + + Future _addNode({required bool isTrigger}) async { + final kind = await showModalBottomSheet( + context: context, + builder: (BuildContext context) { + final options = isTrigger + ? const [ + AutomationNodeKind.watchFileChanged, + AutomationNodeKind.watchDirectoryChanged, + AutomationNodeKind.turnCompleted, + ] + : const [ + AutomationNodeKind.didPathChangeSinceLastRun, + AutomationNodeKind.ifElse, + AutomationNodeKind.quit, + AutomationNodeKind.downloadChangedFile, + AutomationNodeKind.installDownloadedApk, + AutomationNodeKind.sendMessageToCurrentThread, + AutomationNodeKind.runCommand, + ]; + return SafeArea( + child: Wrap( + children: options + .map( + (kind) => ListTile( + leading: Icon(kind.icon), + title: Text(kind.title), + onTap: () => Navigator.of(context).pop(kind), + ), + ) + .toList(), + ), + ); + }, + ); + if (kind == null || !mounted) { + return; + } + final draft = AutomationNode( + id: 'node-${DateTime.now().microsecondsSinceEpoch}', + kind: kind, + ); + final edited = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return AutomationNodeEditorPage( + controller: widget.controller, + node: draft, + ); + }, + ), + ); + if (edited == null) { + return; + } + setState(() { + if (kind.isTrigger) { + _nodes.removeWhere((node) => node.kind.isTrigger); + _nodes.insert(0, edited); + } else { + _nodes.add(edited); + } + }); + } + + Future _editNode(int index) async { + final edited = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return AutomationNodeEditorPage( + controller: widget.controller, + node: _nodes[index], + ); + }, + ), + ); + if (edited == null) { + return; + } + setState(() { + _nodes[index] = edited; + if (edited.kind.isTrigger) { + final triggerIndex = _nodes.indexWhere((node) => node.id == edited.id); + if (triggerIndex > 0) { + final trigger = _nodes.removeAt(triggerIndex); + _nodes.insert(0, trigger); + } + } + }); + } + + Future _saveAutomation() async { + final name = _nameController.text.trim(); + final hasTrigger = _nodes.any((node) => node.kind.isTrigger); + final hasAction = _nodes.any((node) => !node.kind.isTrigger); + if (name.isEmpty || !hasTrigger || !hasAction) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Automation needs a name, one trigger, and at least one action.', + ), + ), + ); + return; + } + await widget.controller.saveAutomation( + widget.initialAutomation.copyWith( + name: name, + enabled: _enabled, + nodes: List.from(_nodes), + ), + ); + if (!mounted) { + return; + } + Navigator.of(context).pop(); + } +} + +class _AutomationNodeCard extends StatelessWidget { + const _AutomationNodeCard({ + required this.index, + required this.node, + required this.onEdit, + required this.onDelete, + }); + + final int index; + final AutomationNode node; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + leading: CircleAvatar( + radius: 14, + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12), + child: Text('${index + 1}', style: theme.textTheme.labelSmall), + ), + title: Text(node.kind.title), + subtitle: Text( + _automationNodeSummary(node), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: onEdit, + trailing: IconButton( + tooltip: 'Delete node', + onPressed: onDelete, + icon: const Icon(Icons.close), + ), + ), + ); + } +} + +class AutomationNodeEditorPage extends StatefulWidget { + const AutomationNodeEditorPage({ + super.key, + required this.controller, + required this.node, + }); + + final AppController controller; + final AutomationNode node; + + @override + State createState() => + _AutomationNodeEditorPageState(); +} + +class _AutomationNodeEditorPageState extends State { + late final TextEditingController _pathController; + late final TextEditingController _commandController; + late final TextEditingController _cwdController; + late final TextEditingController _directoryController; + late final TextEditingController _conditionTokenController; + late AutomationBranchOutcome _whenTrue; + late AutomationBranchOutcome _whenFalse; + + @override + void initState() { + super.initState(); + _pathController = TextEditingController(text: widget.node.path); + _commandController = TextEditingController(text: widget.node.commandText); + _cwdController = TextEditingController(text: widget.node.cwd); + _directoryController = TextEditingController(text: widget.node.directory); + _conditionTokenController = TextEditingController( + text: widget.node.conditionToken, + ); + _whenTrue = widget.node.whenTrue; + _whenFalse = widget.node.whenFalse; + } + + @override + void dispose() { + _pathController.dispose(); + _commandController.dispose(); + _cwdController.dispose(); + _directoryController.dispose(); + _conditionTokenController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final node = widget.node; + final theme = Theme.of(context); + return Theme( + data: theme.copyWith( + splashFactory: InkRipple.splashFactory, + useMaterial3: false, + ), + child: Scaffold( + appBar: AppBar( + title: Text(node.kind.title), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: _AutomationActionButton(label: 'Save', onTap: _saveNode), + ), + ], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + if (node.kind == AutomationNodeKind.watchFileChanged || + node.kind == + AutomationNodeKind.watchDirectoryChanged) ...[ + Row( + children: [ + Expanded( + child: TextField( + controller: _pathController, + decoration: InputDecoration( + labelText: + node.kind == + AutomationNodeKind.watchDirectoryChanged + ? 'Folder path' + : 'File path', + hintText: + node.kind == + AutomationNodeKind.watchDirectoryChanged + ? '/workspace/app' + : '/workspace/app/build/app-release.apk', + ), + ), + ), + const SizedBox(width: 8), + _AutomationIconAction( + tooltip: 'Browse remote files', + icon: Icons.folder_open_outlined, + onTap: () => _browseForPath( + node.kind, + allowDirectorySelection: + node.kind == + AutomationNodeKind.watchDirectoryChanged, + allowFileSelection: + node.kind != + AutomationNodeKind.watchDirectoryChanged, + ), + ), + ], + ), + ], + if (node.kind == AutomationNodeKind.turnCompleted) ...[ + const Text( + 'Triggers after the app-server reports an LLM turn completed. No filesystem path is required.', + ), + const SizedBox(height: 8), + Text( + 'Use this with actions like Run command to start follow-up automation after a response finishes.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (node.kind == AutomationNodeKind.watchFileChanged || + node.kind == + AutomationNodeKind.watchDirectoryChanged) ...[ + const SizedBox(height: 8), + Text( + 'Pick the trigger target from the remote file explorer.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (node.kind == + AutomationNodeKind.didPathChangeSinceLastRun) ...[ + Row( + children: [ + Expanded( + child: TextField( + controller: _pathController, + decoration: const InputDecoration( + labelText: 'File or folder path', + hintText: '/workspace/app/build/app-release.apk', + ), + ), + ), + const SizedBox(width: 8), + _AutomationIconAction( + tooltip: 'Browse remote files', + icon: Icons.folder_open_outlined, + onTap: () => _browseForPath( + node.kind, + allowDirectorySelection: true, + allowFileSelection: true, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Compares the selected file or folder against the previous execution of this automation and stores {{previous.changed}} for the next node.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (node.kind == AutomationNodeKind.ifElse) ...[ + TextField( + controller: _conditionTokenController, + decoration: const InputDecoration( + labelText: 'Condition value or template', + hintText: '{{previous.changed}}', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _whenTrue, + decoration: const InputDecoration(labelText: 'When true'), + items: AutomationBranchOutcome.values + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(_branchOutcomeLabel(value)), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + _whenTrue = value; + }); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _whenFalse, + decoration: const InputDecoration(labelText: 'When false'), + items: AutomationBranchOutcome.values + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(_branchOutcomeLabel(value)), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + _whenFalse = value; + }); + }, + ), + const SizedBox(height: 8), + Text( + 'Defaults to {{previous.changed}} so it can branch after a Did file or folder change node.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (node.kind == AutomationNodeKind.quit) ...[ + const Text( + 'Stops the automation immediately when this node is reached.', + ), + ], + if (node.kind == + AutomationNodeKind.downloadChangedFile) ...[ + Text( + 'Downloads the file path reported by the trigger. If no explicit directory is set here, the automation uses the remembered download directory for the active thread.', + ), + const SizedBox(height: 8), + Text( + 'Optional templates: {{trigger.changedPath}}, {{previous.downloadedPath}}, {{node.someId.downloadedPath}}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TextField( + controller: _directoryController, + decoration: const InputDecoration( + labelText: 'Download directory (optional)', + hintText: '/storage/emulated/0/Download', + ), + ), + ], + if (node.kind == + AutomationNodeKind.installDownloadedApk) ...[ + const Text( + 'Opens the downloaded APK with the system installer. By default it uses the previous download node output.', + ), + const SizedBox(height: 8), + Text( + 'Optional path override or template: {{previous.downloadedPath}}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TextField( + controller: _pathController, + decoration: const InputDecoration( + labelText: 'APK path override (optional)', + hintText: '{{previous.downloadedPath}}', + ), + ), + ], + if (node.kind == + AutomationNodeKind.sendMessageToCurrentThread) ...[ + TextField( + controller: _commandController, + decoration: const InputDecoration( + labelText: 'Message', + hintText: 'A new APK build is ready.', + ), + maxLines: 4, + minLines: 2, + ), + const SizedBox(height: 8), + Text( + 'Templates: {{trigger.changedPath}}, {{previous.downloadedPath}}, {{previous.stdout}}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (node.kind == AutomationNodeKind.runCommand) ...[ + TextField( + controller: _commandController, + decoration: const InputDecoration( + labelText: 'Command', + hintText: 'flutter build apk --release', + ), + maxLines: 3, + minLines: 1, + ), + const SizedBox(height: 12), + TextField( + controller: _cwdController, + decoration: const InputDecoration( + labelText: 'Working directory (optional)', + hintText: '/workspace/app', + ), + ), + const SizedBox(height: 8), + Text( + 'Templates: {{trigger.changedPath}}, {{trigger.path}}, {{previous.stdout}}, {{previous.downloadedPath}}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ), + ); + } + + void _saveNode() { + final next = widget.node.copyWith( + path: _pathController.text.trim(), + commandText: _commandController.text.trim(), + cwd: _cwdController.text.trim(), + directory: _directoryController.text.trim(), + conditionToken: _conditionTokenController.text.trim(), + whenTrue: _whenTrue, + whenFalse: _whenFalse, + ); + final needsPath = + next.kind == AutomationNodeKind.watchFileChanged || + next.kind == AutomationNodeKind.watchDirectoryChanged || + next.kind == AutomationNodeKind.didPathChangeSinceLastRun; + final needsCommand = + next.kind == AutomationNodeKind.runCommand || + next.kind == AutomationNodeKind.sendMessageToCurrentThread; + if (needsPath && next.path.isEmpty) { + _showValidation('An absolute path is required.'); + return; + } + if (needsCommand && next.commandText.isEmpty) { + _showValidation( + next.kind == AutomationNodeKind.sendMessageToCurrentThread + ? 'A message is required.' + : 'A command is required.', + ); + return; + } + Navigator.of(context).pop(next); + } + + Future _browseForPath( + AutomationNodeKind kind, { + bool allowDirectorySelection = false, + bool allowFileSelection = true, + }) async { + final selectedPath = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return AutomationPathPickerPage( + controller: widget.controller, + allowDirectorySelection: allowDirectorySelection, + allowFileSelection: allowFileSelection, + title: allowDirectorySelection && allowFileSelection + ? 'Select file or folder' + : kind == AutomationNodeKind.watchDirectoryChanged + ? 'Select watched folder' + : 'Select watched file', + initialPath: _pathController.text.trim(), + ); + }, + ), + ); + if (selectedPath == null) { + return; + } + _pathController + ..text = selectedPath + ..selection = TextSelection.collapsed(offset: selectedPath.length); + } + + void _showValidation(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class AutomationPathPickerPage extends StatefulWidget { + const AutomationPathPickerPage({ + super.key, + required this.controller, + required this.allowDirectorySelection, + required this.allowFileSelection, + required this.title, + this.initialPath, + }); + + final AppController controller; + final bool allowDirectorySelection; + final bool allowFileSelection; + final String title; + final String? initialPath; + + @override + State createState() => + _AutomationPathPickerPageState(); +} + +class _AutomationPathPickerPageState extends State { + late final TextEditingController _pathController; + + @override + void initState() { + super.initState(); + final initial = widget.initialPath?.trim(); + final initialDirectory = _initialDirectory(initial); + _pathController = TextEditingController(text: initialDirectory); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(widget.controller.loadDirectory(initialDirectory)); + }); + } + + @override + void dispose() { + _pathController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (BuildContext context, Widget? child) { + final controller = widget.controller; + final theme = Theme.of(context); + if (_pathController.text != controller.fileBrowserPath && + controller.fileBrowserPath.isNotEmpty) { + _pathController.value = _pathController.value.copyWith( + text: controller.fileBrowserPath, + selection: TextSelection.collapsed( + offset: controller.fileBrowserPath.length, + ), + ); + } + return Theme( + data: theme.copyWith( + splashFactory: InkRipple.splashFactory, + useMaterial3: false, + ), + child: Scaffold( + appBar: AppBar( + title: Text(widget.title), + actions: [ + if (widget.allowDirectorySelection) + TextButton( + onPressed: controller.fileBrowserPath.trim().isEmpty + ? null + : () => Navigator.of( + context, + ).pop(controller.fileBrowserPath), + child: const Text('Select'), + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + children: [ + Row( + children: [ + IconButton( + tooltip: 'Up', + onPressed: controller.fileBrowserPath == '/' + ? null + : controller.navigateToParentDirectory, + icon: const Icon(Icons.arrow_upward), + ), + Expanded( + child: TextField( + controller: _pathController, + decoration: const InputDecoration( + labelText: 'Absolute path', + ), + onSubmitted: controller.loadDirectory, + ), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: controller.isLoadingFiles + ? null + : () => controller.loadDirectory( + _pathController.text, + ), + child: const Text('Open'), + ), + ], + ), + if (controller.fileBrowserError != null) ...[ + const SizedBox(height: 8), + Text( + controller.fileBrowserError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: + controller.isLoadingFiles && + controller.fileBrowserEntries.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: controller.fileBrowserEntries.length, + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (BuildContext context, int index) { + final entry = + controller.fileBrowserEntries[index]; + final fullPath = controller + .joinFileBrowserPath(entry.fileName); + return ListTile( + dense: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + tileColor: + theme.colorScheme.surfaceContainerLow, + leading: Icon( + entry.isDirectory + ? Icons.folder_outlined + : Icons.insert_drive_file_outlined, + ), + title: Text( + entry.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () async { + if (entry.isDirectory) { + await controller.loadDirectory( + fullPath, + ); + return; + } + if (widget.allowFileSelection && + entry.isFile) { + if (!mounted) { + return; + } + Navigator.of(context).pop(fullPath); + } + }, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + String _initialDirectory(String? initialPath) { + if (initialPath == null || initialPath.isEmpty) { + return widget.controller.preferredFileBrowserRoot; + } + if (widget.allowDirectorySelection) { + return initialPath; + } + final slashIndex = initialPath.lastIndexOf('/'); + if (slashIndex <= 0) { + return '/'; + } + return initialPath.substring(0, slashIndex); + } +} + +String _automationNodeSummary(AutomationNode node) { + switch (node.kind) { + case AutomationNodeKind.watchFileChanged: + case AutomationNodeKind.watchDirectoryChanged: + return node.path.trim().isEmpty ? 'No path configured' : node.path.trim(); + case AutomationNodeKind.turnCompleted: + return 'Runs after an LLM turn completes.'; + case AutomationNodeKind.didPathChangeSinceLastRun: + return node.path.trim().isEmpty + ? 'Compare a file or folder against the previous automation run' + : 'Compare ${node.path.trim()} against the previous automation run'; + case AutomationNodeKind.ifElse: + final condition = node.conditionToken.trim().isEmpty + ? '{{previous.changed}}' + : node.conditionToken.trim(); + return 'If $condition → ${_branchOutcomeLabel(node.whenTrue)} / ${_branchOutcomeLabel(node.whenFalse)}'; + case AutomationNodeKind.quit: + return 'Stop the automation immediately.'; + case AutomationNodeKind.downloadChangedFile: + return node.directory.trim().isEmpty + ? 'Download to remembered thread directory' + : 'Download to ${node.directory.trim()}'; + case AutomationNodeKind.installDownloadedApk: + return 'Install the APK that was downloaded by an earlier node.'; + case AutomationNodeKind.sendMessageToCurrentThread: + final message = node.commandText.trim(); + return message.isEmpty ? 'No message configured' : message; + case AutomationNodeKind.runCommand: + final command = node.commandText.trim(); + final cwd = node.cwd.trim(); + if (command.isEmpty) { + return 'No command configured'; + } + if (cwd.isEmpty) { + return command; + } + return '$command • $cwd'; + } +} + +String _branchOutcomeLabel(AutomationBranchOutcome value) { + return switch (value) { + AutomationBranchOutcome.continueFlow => 'Continue', + AutomationBranchOutcome.quitFlow => 'Quit', + }; +} diff --git a/lib/src/features/commands/domain/command_models.dart b/lib/src/features/commands/domain/command_models.dart new file mode 100644 index 0000000..2b45aeb --- /dev/null +++ b/lib/src/features/commands/domain/command_models.dart @@ -0,0 +1,72 @@ +import '../../settings/domain/app_settings.dart'; + +enum CommandSessionMode { buffered, interactive } + +class RecentCommand { + const RecentCommand({ + required this.commandText, + required this.cwd, + required this.mode, + required this.sandboxMode, + required this.allowNetwork, + required this.disableTimeout, + required this.timeoutMs, + required this.disableOutputCap, + required this.outputBytesCap, + }); + + final String commandText; + final String cwd; + final CommandSessionMode mode; + final SandboxMode sandboxMode; + final bool allowNetwork; + final bool disableTimeout; + final int timeoutMs; + final bool disableOutputCap; + final int outputBytesCap; +} + +class CommandSession { + CommandSession({ + required this.id, + required this.processId, + required this.commandDisplay, + required this.cwd, + required this.mode, + required this.usesTty, + required this.startedAt, + this.exitCode, + this.stdout = '', + this.stderr = '', + this.status = 'running', + this.stdinClosed = false, + this.outputCapReached = false, + }); + + final String id; + final String processId; + final String commandDisplay; + final String cwd; + final CommandSessionMode mode; + final bool usesTty; + final DateTime startedAt; + int? exitCode; + String stdout; + String stderr; + String status; + bool stdinClosed; + bool outputCapReached; + + bool get isRunning => status == 'running'; + bool get isInteractive => mode == CommandSessionMode.interactive; + + String get statusLabel { + if (isRunning) { + return 'running'; + } + if (exitCode != null) { + return 'exit $exitCode'; + } + return status; + } +} diff --git a/lib/src/features/commands/presentation/command_center_page.dart b/lib/src/features/commands/presentation/command_center_page.dart new file mode 100644 index 0000000..6b01290 --- /dev/null +++ b/lib/src/features/commands/presentation/command_center_page.dart @@ -0,0 +1,1477 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../app_controller.dart'; +import '../../../models.dart'; +import '../../../core/widgets/monospace_output_view.dart'; + +class CommandCenterPage extends StatefulWidget { + const CommandCenterPage({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _CommandCenterPageState(); +} + +class _CommandCenterPageState extends State { + late final TextEditingController _commandController; + late final TextEditingController _stdinController; + late final TextEditingController _cwdController; + late final TextEditingController _timeoutController; + late final TextEditingController _outputCapController; + late SandboxMode _sandboxMode; + late bool _allowNetwork; + bool _disableTimeout = true; + bool _disableOutputCap = true; + int _lastRows = 0; + int _lastCols = 0; + + @override + void initState() { + super.initState(); + final controller = widget.controller; + final settings = controller.settings; + _commandController = TextEditingController(); + _commandController.addListener(_handleLocalInputChanged); + _stdinController = TextEditingController(); + _stdinController.addListener(_handleLocalInputChanged); + _cwdController = TextEditingController( + text: controller.preferredCommandCwd, + ); + _timeoutController = TextEditingController(text: '60000'); + _outputCapController = TextEditingController(text: '32768'); + _sandboxMode = settings.sandboxMode; + _allowNetwork = settings.allowNetwork; + } + + @override + void dispose() { + _commandController.removeListener(_handleLocalInputChanged); + _commandController.dispose(); + _stdinController.removeListener(_handleLocalInputChanged); + _stdinController.dispose(); + _cwdController.dispose(); + _timeoutController.dispose(); + _outputCapController.dispose(); + super.dispose(); + } + + void _handleLocalInputChanged() { + if (!mounted) { + return; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (BuildContext context, Widget? child) { + final activeSession = widget.controller.activeCommandSession; + final preferredCwd = widget.controller.preferredCommandCwd; + final runningSessionCount = widget.controller.commandSessions + .where((session) => session.isRunning) + .length; + if (_commandController.text.isEmpty && + _cwdController.text.trim().isEmpty && + preferredCwd.isNotEmpty) { + _cwdController.text = preferredCwd; + } + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + leading: IconButton( + tooltip: 'Command settings', + onPressed: _openSettingsModal, + icon: const Icon(Icons.settings_outlined), + ), + title: const Text('Command Center'), + actions: [ + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.close), + ), + ], + ), + body: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final wideLayout = constraints.maxWidth >= 960; + final historyPanel = _CommandHistoryPanel( + controller: widget.controller, + canRepeat: _commandController.text.trim().isEmpty, + onRepeat: _repeatCommandFromHistory, + ); + final terminalPanel = _CommandSessionView( + session: activeSession, + stdinController: _stdinController, + onSubmitStdin: _submitTerminalInput, + onSendQuickInput: activeSession == null + ? null + : (String input) => widget.controller + .writeToCommandSession(activeSession.id, input), + onTerminate: activeSession == null + ? null + : () => widget.controller.terminateCommandSession( + activeSession.id, + ), + onResize: activeSession == null + ? null + : (int rows, int cols) { + if (_lastRows == rows && _lastCols == cols) { + return; + } + _lastRows = rows; + _lastCols = cols; + widget.controller.resizeCommandSession( + activeSession.id, + rows: rows, + cols: cols, + ); + }, + ); + + final launcher = _CommandLauncherCard( + commandController: _commandController, + cwdController: _cwdController, + sandboxMode: _sandboxMode, + allowNetwork: _allowNetwork, + disableTimeout: _disableTimeout, + timeoutMs: + int.tryParse(_timeoutController.text.trim()) ?? 0, + disableOutputCap: _disableOutputCap, + outputBytesCap: + int.tryParse(_outputCapController.text.trim()) ?? 0, + runningSessionCount: runningSessionCount, + onRun: _runCommand, + onOpenSettings: _openSettingsModal, + ); + + if (wideLayout) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: constraints.maxWidth * 0.34, + child: launcher, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 156, child: historyPanel), + const SizedBox(height: 14), + Expanded(child: terminalPanel), + ], + ), + ), + ], + ); + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + launcher, + const SizedBox(height: 14), + SizedBox(height: 108, child: historyPanel), + const SizedBox(height: 14), + SizedBox(height: 360, child: terminalPanel), + ], + ), + ); + }, + ), + ), + ), + ); + }, + ); + } + + Future _runCommand() async { + final commandText = _commandController.text; + if (commandText.trim().isEmpty) { + return; + } + final timeoutMs = int.tryParse(_timeoutController.text.trim()) ?? 0; + final outputCap = int.tryParse(_outputCapController.text.trim()) ?? 0; + final disableTimeout = + _disableTimeout || _looksLikeFlutterBuildCommand(commandText); + await widget.controller.startCommandExecution( + commandText: commandText, + cwd: _cwdController.text, + sandboxMode: _sandboxMode, + allowNetwork: _allowNetwork, + mode: CommandSessionMode.interactive, + timeoutMs: timeoutMs, + disableTimeout: disableTimeout, + outputBytesCap: outputCap, + disableOutputCap: _disableOutputCap, + rows: 24, + cols: 96, + ); + _commandController.clear(); + } + + bool _looksLikeFlutterBuildCommand(String commandText) { + return RegExp(r'(^|\s)flutter\s+build(\s|$)').hasMatch(commandText.trim()); + } + + Future _sendCommandInput(CommandSession session) async { + final text = _stdinController.text; + if (text.isEmpty) { + return; + } + _stdinController.clear(); + await widget.controller.writeToCommandSession(session.id, '$text\n'); + } + + Future _submitTerminalInput() async { + final session = widget.controller.activeCommandSession; + final canSendToInteractive = + session != null && + session.isInteractive && + session.isRunning && + !session.stdinClosed; + if (!canSendToInteractive) { + return; + } + await _sendCommandInput(session); + } + + void _applyRecentCommand(RecentCommand recent) { + _commandController.text = recent.commandText; + _cwdController.text = recent.cwd; + _timeoutController.text = recent.timeoutMs.toString(); + _outputCapController.text = recent.outputBytesCap.toString(); + setState(() { + _sandboxMode = recent.sandboxMode; + _allowNetwork = recent.allowNetwork; + _disableTimeout = recent.disableTimeout; + _disableOutputCap = recent.disableOutputCap; + }); + } + + void _repeatCommandFromHistory(CommandSession session) { + if (_commandController.text.trim().isNotEmpty) { + return; + } + final command = session.commandDisplay.trim(); + if (command.isEmpty) { + return; + } + _commandController + ..text = command + ..selection = TextSelection.collapsed(offset: command.length); + } + + Future _openSettingsModal() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setModalState) { + return FractionallySizedBox( + heightFactor: 0.82, + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CommandForm( + cwdController: _cwdController, + timeoutController: _timeoutController, + outputCapController: _outputCapController, + sandboxMode: _sandboxMode, + allowNetwork: _allowNetwork, + disableTimeout: _disableTimeout, + disableOutputCap: _disableOutputCap, + onSandboxChanged: (SandboxMode value) { + setState(() => _sandboxMode = value); + setModalState(() {}); + }, + onAllowNetworkChanged: (bool value) { + setState(() => _allowNetwork = value); + setModalState(() {}); + }, + onDisableTimeoutChanged: (bool value) { + setState(() => _disableTimeout = value); + setModalState(() {}); + }, + onDisableOutputCapChanged: (bool value) { + setState(() => _disableOutputCap = value); + setModalState(() {}); + }, + ), + const SizedBox(height: 12), + _SavedCommandPanel( + controller: widget.controller, + onTapCommand: (RecentCommand recent) { + _applyRecentCommand(recent); + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } +} + +class _CommandForm extends StatelessWidget { + const _CommandForm({ + required this.cwdController, + required this.timeoutController, + required this.outputCapController, + required this.sandboxMode, + required this.allowNetwork, + required this.disableTimeout, + required this.disableOutputCap, + required this.onSandboxChanged, + required this.onAllowNetworkChanged, + required this.onDisableTimeoutChanged, + required this.onDisableOutputCapChanged, + }); + + final TextEditingController cwdController; + final TextEditingController timeoutController; + final TextEditingController outputCapController; + final SandboxMode sandboxMode; + final bool allowNetwork; + final bool disableTimeout; + final bool disableOutputCap; + final ValueChanged onSandboxChanged; + final ValueChanged onAllowNetworkChanged; + final ValueChanged onDisableTimeoutChanged; + final ValueChanged onDisableOutputCapChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Setup', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.terminal_outlined), + title: const Text('Interactive shell'), + subtitle: const Text('Commands always run in interactive mode.'), + ), + const SizedBox(height: 12), + TextField( + controller: cwdController, + decoration: const InputDecoration(labelText: 'Working directory'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: sandboxMode, + decoration: const InputDecoration(labelText: 'Sandbox'), + items: SandboxMode.values + .map( + (SandboxMode item) => DropdownMenuItem( + value: item, + child: Text(item.name), + ), + ) + .toList(), + onChanged: (SandboxMode? value) { + if (value != null) { + onSandboxChanged(value); + } + }, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Network'), + value: allowNetwork, + onChanged: onAllowNetworkChanged, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Disable timeout'), + value: disableTimeout, + onChanged: onDisableTimeoutChanged, + ), + if (!disableTimeout) ...[ + TextField( + controller: timeoutController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Timeout ms'), + ), + const SizedBox(height: 8), + ], + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Disable output cap'), + value: disableOutputCap, + onChanged: onDisableOutputCapChanged, + ), + if (!disableOutputCap) ...[ + TextField( + controller: outputCapController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Output cap bytes'), + ), + const SizedBox(height: 8), + ], + ], + ), + ); + } +} + +class _CommandLauncherCard extends StatelessWidget { + const _CommandLauncherCard({ + required this.commandController, + required this.cwdController, + required this.sandboxMode, + required this.allowNetwork, + required this.disableTimeout, + required this.timeoutMs, + required this.disableOutputCap, + required this.outputBytesCap, + required this.runningSessionCount, + required this.onRun, + required this.onOpenSettings, + }); + + final TextEditingController commandController; + final TextEditingController cwdController; + final SandboxMode sandboxMode; + final bool allowNetwork; + final bool disableTimeout; + final int timeoutMs; + final bool disableOutputCap; + final int outputBytesCap; + final int runningSessionCount; + final Future Function() onRun; + final VoidCallback onOpenSettings; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final runEnabled = commandController.text.trim().isNotEmpty; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.terminal_rounded, color: scheme.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Launch Command', style: theme.textTheme.titleMedium), + const SizedBox(height: 2), + Text( + runningSessionCount == 0 + ? 'Start a fresh shell session.' + : '$runningSessionCount live session${runningSessionCount == 1 ? '' : 's'} connected.', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + OutlinedButton.icon( + onPressed: onOpenSettings, + icon: const Icon(Icons.tune_rounded, size: 18), + label: const Text('Options'), + ), + ], + ), + const SizedBox(height: 14), + TextField( + key: const ValueKey('command-shell-input'), + controller: commandController, + onSubmitted: (_) { + unawaited(onRun()); + }, + textInputAction: TextInputAction.go, + maxLines: 1, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + decoration: const InputDecoration( + labelText: 'Command', + hintText: 'npm test or flutter build apk --release', + prefixIcon: Icon(Icons.code_rounded), + ), + ), + const SizedBox(height: 12), + TextField( + controller: cwdController, + maxLines: 1, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + decoration: const InputDecoration( + labelText: 'Working directory', + hintText: '/workspace/project', + prefixIcon: Icon(Icons.folder_open_rounded), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CommandSettingChip( + icon: Icons.shield_outlined, + label: _sandboxLabel(sandboxMode), + ), + _CommandSettingChip( + icon: allowNetwork + ? Icons.public_rounded + : Icons.public_off_rounded, + label: allowNetwork ? 'Network on' : 'Network off', + ), + _CommandSettingChip( + icon: Icons.timer_outlined, + label: disableTimeout + ? 'No timeout' + : '${timeoutMs <= 0 ? 60000 : timeoutMs} ms', + ), + _CommandSettingChip( + icon: Icons.unfold_more_rounded, + label: disableOutputCap + ? 'Uncapped output' + : '${outputBytesCap <= 0 ? 32768 : outputBytesCap} bytes', + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: Text( + 'Commands run via `/bin/bash -lc` and keep streaming output in the selected session below.', + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: runEnabled + ? () { + unawaited(onRun()); + } + : null, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Run'), + ), + ], + ), + ], + ), + ); + } +} + +class _CommandSettingChip extends StatelessWidget { + const _CommandSettingChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: scheme.onSurfaceVariant), + const SizedBox(width: 6), + Text(label, style: theme.textTheme.bodySmall), + ], + ), + ); + } +} + +class _CommandSessionCard extends StatelessWidget { + const _CommandSessionCard({ + required this.session, + required this.selected, + required this.onTap, + required this.canRepeat, + required this.onRepeat, + required this.onTerminate, + }); + + final CommandSession session; + final bool selected; + final VoidCallback onTap; + final bool canRepeat; + final VoidCallback onRepeat; + final VoidCallback onTerminate; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final statusColor = session.isRunning + ? scheme.primary + : session.exitCode == 0 + ? scheme.secondary + : scheme.error; + + return SizedBox( + width: 250, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: selected + ? scheme.primary.withValues(alpha: 0.10) + : scheme.surfaceContainerLow, + border: Border.all( + color: selected ? scheme.primary : theme.dividerColor, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: selected + ? [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.10), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : const [], + ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Row( + children: [ + Expanded( + child: Text( + session.commandDisplay, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: selected ? scheme.onSurface : null, + ), + ), + ), + const SizedBox(width: 8), + Text( + session.outputCapReached + ? '${session.statusLabel} • cap' + : session.statusLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Repeat', + onPressed: canRepeat ? onRepeat : null, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + splashRadius: 16, + icon: const Icon(Icons.replay_rounded, size: 16), + ), + if (session.isRunning) + IconButton( + tooltip: 'Terminate', + onPressed: onTerminate, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + splashRadius: 16, + icon: const Icon(Icons.stop_circle_outlined, size: 16), + ), + ], + ), + ), + ), + ); + } +} + +class _RecentCommandCard extends StatelessWidget { + const _RecentCommandCard({ + required this.command, + required this.onTap, + required this.onRemove, + }); + + final RecentCommand command; + final VoidCallback onTap; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + command.commandText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 6), + Text( + command.sandboxMode.name, + style: theme.textTheme.bodySmall, + ), + if (command.cwd.isNotEmpty) + Text( + command.cwd, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + tooltip: 'Remove saved command', + onPressed: onRemove, + visualDensity: const VisualDensity(horizontal: -4, vertical: -4), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + splashRadius: 16, + icon: const Icon(Icons.close, size: 16), + ), + ], + ), + ), + ); + } +} + +class _CommandSessionView extends StatefulWidget { + const _CommandSessionView({ + required this.session, + required this.stdinController, + required this.onSubmitStdin, + required this.onSendQuickInput, + required this.onTerminate, + required this.onResize, + }); + + final CommandSession? session; + final TextEditingController stdinController; + final Future Function() onSubmitStdin; + final Future Function(String input)? onSendQuickInput; + final VoidCallback? onTerminate; + final void Function(int rows, int cols)? onResize; + + @override + State<_CommandSessionView> createState() => _CommandSessionViewState(); +} + +class _CommandSessionViewState extends State<_CommandSessionView> { + final ScrollController _verticalOutputController = ScrollController(); + final ScrollController _horizontalOutputController = ScrollController(); + + @override + void didUpdateWidget(covariant _CommandSessionView oldWidget) { + super.didUpdateWidget(oldWidget); + final previousId = oldWidget.session?.id; + final nextId = widget.session?.id; + final previousOutputLength = + (oldWidget.session?.stdout.length ?? 0) + + (oldWidget.session?.stderr.length ?? 0); + final nextOutputLength = + (widget.session?.stdout.length ?? 0) + + (widget.session?.stderr.length ?? 0); + if (previousId != nextId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_horizontalOutputController.hasClients) { + _horizontalOutputController.jumpTo(0); + } + _scrollToLatest(force: true); + }); + return; + } + if (nextOutputLength > previousOutputLength) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToLatest(); + }); + } + } + + @override + void dispose() { + _verticalOutputController.dispose(); + _horizontalOutputController.dispose(); + super.dispose(); + } + + void _scrollToLatest({bool force = false}) { + if (!_verticalOutputController.hasClients) { + return; + } + final position = _verticalOutputController.position; + final distanceFromBottom = position.maxScrollExtent - position.pixels; + final shouldFollow = force || distanceFromBottom < 56; + if (!shouldFollow) { + return; + } + _verticalOutputController.animateTo( + position.maxScrollExtent, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + final session = widget.session; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final outputBackground = Color.alphaBlend( + scheme.primary.withValues(alpha: 0.05), + scheme.surfaceContainerLow, + ); + final interactiveInput = + session != null && + session.isInteractive && + session.isRunning && + !session.stdinClosed; + return Container( + key: const ValueKey('command-shell-panel'), + decoration: BoxDecoration( + color: scheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session?.commandDisplay ?? 'Shell', + key: const ValueKey('command-shell-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineTerminalPill( + label: session?.statusLabel ?? 'idle', + color: session == null + ? scheme.onSurfaceVariant + : session.isRunning + ? scheme.primary + : session.exitCode == 0 + ? scheme.secondary + : scheme.error, + ), + _InlineTerminalPill( + label: session?.usesTty == true ? 'PTY' : 'stream', + ), + if (session?.stdinClosed == true) + const _InlineTerminalPill(label: 'stdin closed'), + ], + ), + const SizedBox(height: 8), + Text( + session?.cwd.isNotEmpty == true + ? session!.cwd + : 'Select a session or run a command to stream output here.', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (widget.onTerminate != null) + IconButton( + tooltip: 'Terminate', + onPressed: session?.isRunning == true + ? widget.onTerminate + : null, + icon: const Icon(Icons.stop_circle_outlined), + ), + ], + ), + ), + Divider(height: 1, color: theme.dividerColor), + Expanded( + child: Container( + color: outputBackground, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final rows = (constraints.maxHeight / 18).floor().clamp( + 10, + 60, + ); + final cols = (constraints.maxWidth / 8).floor().clamp( + 40, + 160, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (session != null && + session.isInteractive && + session.usesTty && + session.isRunning && + widget.onResize != null) { + widget.onResize!(rows, cols); + } + }); + return Scrollbar( + controller: _verticalOutputController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _verticalOutputController, + primary: false, + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: Scrollbar( + controller: _horizontalOutputController, + thumbVisibility: true, + notificationPredicate: (notification) => + notification.metrics.axis == Axis.horizontal, + child: SingleChildScrollView( + controller: _horizontalOutputController, + primary: false, + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth - 28, + minHeight: constraints.maxHeight > 28 + ? constraints.maxHeight - 28 + : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (session != null && + session.stdout.isNotEmpty) + MonospaceOutputView( + text: session.stdout, + scrollable: false, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + height: 1.24, + color: scheme.onSurface, + ), + ), + if (session == null) + Text( + '\$ Run a command to open a live shell session.', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: scheme.onSurfaceVariant, + ), + ), + if (session != null && + session.stdout.isEmpty && + session.stderr.isEmpty) + Text( + session.isRunning + ? 'Waiting for output...' + : 'Command produced no output.', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: scheme.onSurfaceVariant, + ), + ), + if (session != null && + session.outputCapReached) ...[ + const SizedBox(height: 10), + Text( + 'Output cap reached', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: scheme.secondary, + ), + ), + ], + if (session != null && + session.stderr.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'stderr', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.error, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + MonospaceOutputView( + text: session.stderr, + scrollable: false, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: scheme.error, + height: 1.24, + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + Divider(height: 1, color: theme.dividerColor), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 38, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + _TerminalQuickButton( + label: 'Ctrl+C', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u0003')); + } + : null, + ), + _TerminalQuickButton( + label: 'Ctrl+D', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u0004')); + } + : null, + ), + _TerminalQuickButton( + label: 'Esc', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u001B')); + } + : null, + ), + _TerminalQuickButton( + label: 'Tab', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\t')); + } + : null, + ), + _TerminalQuickButton( + label: '↑', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u001B[A')); + } + : null, + ), + _TerminalQuickButton( + label: '↓', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u001B[B')); + } + : null, + ), + _TerminalQuickButton( + label: '←', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u001B[D')); + } + : null, + ), + _TerminalQuickButton( + label: '→', + onPressed: + interactiveInput && widget.onSendQuickInput != null + ? () { + unawaited(widget.onSendQuickInput!('\u001B[C')); + } + : null, + ), + ], + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\$', + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: scheme.primary, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + key: const ValueKey('command-stdin-input'), + controller: widget.stdinController, + onSubmitted: (_) { + unawaited(widget.onSubmitStdin()); + }, + enabled: interactiveInput, + textInputAction: TextInputAction.send, + maxLines: 1, + cursorColor: scheme.primary, + decoration: InputDecoration( + isDense: true, + hintText: interactiveInput + ? 'stdin to active process' + : 'interactive input is unavailable for this session', + ), + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: scheme.onSurface, + height: 1.2, + ), + ), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: + interactiveInput && + widget.stdinController.text.isNotEmpty + ? () { + unawaited(widget.onSubmitStdin()); + } + : null, + child: const Text('Send'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _CommandHistoryPanel extends StatelessWidget { + const _CommandHistoryPanel({ + required this.controller, + required this.canRepeat, + required this.onRepeat, + }); + + final AppController controller; + final bool canRepeat; + final ValueChanged onRepeat; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasFinishedSessions = controller.commandSessions.any( + (session) => !session.isRunning, + ); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Shell history', style: theme.textTheme.titleMedium), + const Spacer(), + IconButton( + tooltip: 'Clear finished runs', + onPressed: hasFinishedSessions + ? controller.clearFinishedCommandSessions + : null, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + icon: const Icon(Icons.delete_outline), + ), + if (controller.commandSessions.isNotEmpty) + Text( + '${controller.commandSessions.length}', + style: theme.textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 10), + Expanded( + child: controller.commandSessions.isEmpty + ? Center( + child: Text( + 'No shell commands yet.', + style: theme.textTheme.bodySmall, + ), + ) + : ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: controller.commandSessions.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (BuildContext context, int index) { + final session = controller.commandSessions[index]; + final selected = + controller.activeCommandSession?.id == session.id; + return _CommandSessionCard( + session: session, + selected: selected, + canRepeat: canRepeat, + onRepeat: () => onRepeat(session), + onTap: () => + controller.selectCommandSession(session.id), + onTerminate: () => + controller.terminateCommandSession(session.id), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _InlineTerminalPill extends StatelessWidget { + const _InlineTerminalPill({required this.label, this.color}); + + final String label; + final Color? color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveColor = color ?? theme.colorScheme.onSurfaceVariant; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: effectiveColor, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _TerminalQuickButton extends StatelessWidget { + const _TerminalQuickButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 36), + ), + child: Text(label), + ), + ); + } +} + +String _sandboxLabel(SandboxMode mode) { + return switch (mode) { + SandboxMode.workspaceWrite => 'Workspace write', + SandboxMode.readOnly => 'Read only', + SandboxMode.dangerFullAccess => 'Danger full access', + }; +} + +class _SavedCommandPanel extends StatelessWidget { + const _SavedCommandPanel({ + required this.controller, + required this.onTapCommand, + }); + + final AppController controller; + final ValueChanged onTapCommand; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Saved', style: theme.textTheme.titleMedium), + const Spacer(), + if (controller.recentCommands.isNotEmpty) + Text( + '${controller.recentCommands.length}', + style: theme.textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 8), + if (controller.recentCommands.isEmpty) + Text( + 'Saved commands appear here after you run them.', + style: theme.textTheme.bodySmall, + ) + else + ...controller.recentCommands.map((recent) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _RecentCommandCard( + command: recent, + onTap: () => onTapCommand(recent), + onRemove: () => controller.removeRecentCommand(recent), + ), + ); + }), + ], + ), + ); + } +} diff --git a/lib/src/features/diagnostics/presentation/event_log_sheet.dart b/lib/src/features/diagnostics/presentation/event_log_sheet.dart new file mode 100644 index 0000000..031687d --- /dev/null +++ b/lib/src/features/diagnostics/presentation/event_log_sheet.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../../app_controller.dart'; + +class EventLogSheet extends StatelessWidget { + const EventLogSheet({super.key, required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Event log', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.6, + child: ListView.separated( + itemCount: controller.eventLog.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (BuildContext context, int index) { + final entry = controller.eventLog[index]; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.method, style: theme.textTheme.titleMedium), + if (entry.summary.isNotEmpty) ...[ + const SizedBox(height: 6), + SelectableText( + entry.summary, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/downloads/presentation/download_center_page.dart b/lib/src/features/downloads/presentation/download_center_page.dart new file mode 100644 index 0000000..e4b46db --- /dev/null +++ b/lib/src/features/downloads/presentation/download_center_page.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; + +import '../../../app_controller.dart'; +import '../../../core/platform/download_location_opener.dart'; + +class DownloadCenterPage extends StatelessWidget { + const DownloadCenterPage({super.key, required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedBuilder( + animation: controller, + builder: (BuildContext context, Widget? child) { + return Scaffold( + appBar: AppBar( + title: const Text('Downloads'), + actions: [ + TextButton( + onPressed: + controller.downloadRecords.any( + (item) => item.state != DownloadState.running, + ) + ? controller.clearFinishedDownloads + : null, + child: const Text('Clear finished'), + ), + ], + ), + body: SafeArea( + child: controller.downloadRecords.isEmpty + ? Center( + child: Text( + 'No downloads yet.', + style: theme.textTheme.bodyMedium, + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: controller.downloadRecords.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (BuildContext context, int index) { + final record = controller.downloadRecords[index]; + return _DownloadRecordTile( + record: record, + onOpen: record.targetPath == null + ? null + : () => openDownloadedLocation( + context, + record.targetPath!, + ), + onCancel: record.state == DownloadState.running + ? () => controller.cancelFileDownload( + record.sourcePath, + ) + : null, + ); + }, + ), + ), + ); + }, + ); + } +} + +class _DownloadRecordTile extends StatelessWidget { + const _DownloadRecordTile({required this.record, this.onOpen, this.onCancel}); + + final DownloadRecord record; + final VoidCallback? onOpen; + final VoidCallback? onCancel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final status = record.status; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_downloadStateIcon(record.state), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + record.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + ), + Text( + _downloadStateLabel(record.state), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + record.sourcePath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (status != null) ...[ + const SizedBox(height: 10), + LinearProgressIndicator( + value: record.state == DownloadState.running + ? status.progress + : 1, + ), + const SizedBox(height: 6), + Text(_formatTransferSize(status), style: theme.textTheme.bodySmall), + const SizedBox(height: 2), + Text( + record.state == DownloadState.running + ? _formatEta(status) + : _downloadCompletionText(record), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ] else if (record.error != null && + record.error!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + record.error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 10), + Row( + children: [ + if (record.targetPath != null && onOpen != null) + OutlinedButton.icon( + onPressed: onOpen, + icon: const Icon(Icons.folder_open_outlined, size: 18), + label: const Text('Open'), + ), + if (record.state == DownloadState.running && + onCancel != null) ...[ + if (record.targetPath != null) const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, size: 18), + label: const Text('Cancel'), + ), + ], + ], + ), + ], + ), + ); + } +} + +IconData _downloadStateIcon(DownloadState state) { + return switch (state) { + DownloadState.running => Icons.downloading_rounded, + DownloadState.completed => Icons.download_done_outlined, + DownloadState.failed => Icons.error_outline, + DownloadState.cancelled => Icons.remove_circle_outline, + }; +} + +String _downloadStateLabel(DownloadState state) { + return switch (state) { + DownloadState.running => 'Downloading', + DownloadState.completed => 'Completed', + DownloadState.failed => 'Failed', + DownloadState.cancelled => 'Cancelled', + }; +} + +String _downloadCompletionText(DownloadRecord record) { + final finishedAt = record.finishedAt; + if (finishedAt == null) { + return ''; + } + final local = finishedAt; + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return 'Finished at $hour:$minute'; +} + +String _formatTransferSize(FileDownloadStatus? status) { + final received = _formatMegabytes(status?.receivedBytes ?? 0); + final totalBytes = status?.totalBytes; + final total = totalBytes == null ? '--' : _formatMegabytes(totalBytes); + return '$received MB / $total MB'; +} + +String _formatEta(FileDownloadStatus? status) { + final eta = status?.eta; + if (eta == null) { + return 'Estimating time remaining...'; + } + if (eta == Duration.zero) { + return 'Almost done'; + } + final seconds = eta.inSeconds; + if (seconds < 60) { + return '${seconds}s remaining'; + } + final minutes = eta.inMinutes; + final remainingSeconds = seconds % 60; + if (minutes < 60) { + return '${minutes}m ${remainingSeconds}s remaining'; + } + final hours = eta.inHours; + final remainingMinutes = minutes % 60; + return '${hours}h ${remainingMinutes}m remaining'; +} + +String _formatMegabytes(int bytes) { + final megabytes = bytes / (1024 * 1024); + return megabytes.toStringAsFixed(megabytes >= 10 ? 0 : 1); +} diff --git a/lib/src/features/files/presentation/file_pages.dart b/lib/src/features/files/presentation/file_pages.dart new file mode 100644 index 0000000..14b27e0 --- /dev/null +++ b/lib/src/features/files/presentation/file_pages.dart @@ -0,0 +1,757 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../app_controller.dart'; +import '../../../models.dart'; + +class FileBrowserSheet extends StatefulWidget { + const FileBrowserSheet({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _FileBrowserSheetState(); +} + +class _FileBrowserSheetState extends State { + late final TextEditingController _pathController; + + @override + void initState() { + super.initState(); + _pathController = TextEditingController( + text: widget.controller.fileBrowserPath, + ); + } + + @override + void dispose() { + _pathController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedBuilder( + animation: widget.controller, + builder: (BuildContext context, Widget? child) { + final controller = widget.controller; + if (_pathController.text != controller.fileBrowserPath) { + _pathController.value = _pathController.value.copyWith( + text: controller.fileBrowserPath, + selection: TextSelection.collapsed( + offset: controller.fileBrowserPath.length, + ), + ); + } + return Scaffold( + backgroundColor: Colors.transparent, + body: Container( + margin: const EdgeInsets.only(right: 24), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(22), + ), + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: SizedBox( + height: MediaQuery.sizeOf(context).height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Files', + style: theme.textTheme.titleLarge, + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + IconButton( + tooltip: 'Up', + onPressed: controller.fileBrowserPath == '/' + ? null + : controller.navigateToParentDirectory, + icon: const Icon(Icons.arrow_upward), + ), + Expanded( + child: TextField( + controller: _pathController, + decoration: const InputDecoration( + labelText: 'Absolute path', + ), + onSubmitted: controller.loadDirectory, + ), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: controller.isLoadingFiles + ? null + : () => controller.loadDirectory( + _pathController.text, + ), + child: const Text('Open'), + ), + ], + ), + if (controller.fileBrowserError != null) ...[ + const SizedBox(height: 8), + Text( + controller.fileBrowserError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + Expanded(child: _buildFileList(theme, controller)), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + Future _downloadFile(BuildContext context, String filePath) async { + try { + await widget.controller.saveFileToDevice(filePath); + } catch (error) { + // Download errors are shown in the download center. + } + } + + Future _cancelDownload(String filePath) async { + await widget.controller.cancelFileDownload(filePath); + } + + Future _openPreviewForFile(String filePath, {int? line}) async { + await widget.controller.openFile(filePath, highlightedLine: line); + if (!mounted) { + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return FilePreviewPage( + controller: widget.controller, + onDownload: _downloadFile, + onCancelDownload: _cancelDownload, + ); + }, + ), + ); + } + + Widget _buildFileList(ThemeData theme, AppController controller) { + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: controller.isLoadingFiles && controller.fileBrowserEntries.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: controller.fileBrowserEntries.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (BuildContext context, int index) { + final entry = controller.fileBrowserEntries[index]; + final fullPath = controller.joinFileBrowserPath(entry.fileName); + return _FileEntryTile( + controller: controller, + entry: entry, + isDownloading: controller.isFileDownloading(fullPath), + downloadStatus: controller.fileDownloadStatus(fullPath), + onOpenFile: entry.isFile + ? () => _openPreviewForFile(fullPath) + : null, + onDownload: entry.isFile + ? () => _downloadFile(context, fullPath) + : null, + onCancelDownload: entry.isFile + ? () => _cancelDownload(fullPath) + : null, + ); + }, + ), + ); + } +} + +class FilePreviewPage extends StatefulWidget { + const FilePreviewPage({ + super.key, + required this.controller, + required this.onDownload, + required this.onCancelDownload, + }); + + final AppController controller; + final Future Function(BuildContext context, String filePath) onDownload; + final Future Function(String filePath) onCancelDownload; + + @override + State createState() => _FilePreviewPageState(); +} + +class _FilePreviewPageState extends State { + late final TextEditingController _editorController = TextEditingController(); + String? _editingPath; + bool _isEditing = false; + + @override + void dispose() { + _editorController.dispose(); + super.dispose(); + } + + void _syncEditorFromController() { + final controller = widget.controller; + final path = controller.selectedFilePath; + if (!_isEditing && + controller.selectedFileIsHumanReadable && + path != null && + path.isNotEmpty && + _editingPath != path) { + _editingPath = path; + _editorController.text = controller.selectedFileContent ?? ''; + } + if (!_isEditing && !controller.selectedFileIsHumanReadable) { + _editingPath = null; + _editorController.clear(); + } + } + + Future _saveFile(BuildContext context) async { + try { + await widget.controller.saveOpenedFileContent(_editorController.text); + if (!mounted) { + return; + } + setState(() { + _isEditing = false; + }); + } catch (_) { + if (!context.mounted) { + return; + } + final message = widget.controller.filePreviewSaveError?.trim(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + message == null || message.isEmpty + ? 'Unable to save the file.' + : message, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (BuildContext context, Widget? child) { + _syncEditorFromController(); + final controller = widget.controller; + final filePath = controller.selectedFilePath; + final canEdit = + controller.selectedFileIsHumanReadable && + filePath != null && + filePath.isNotEmpty; + return Scaffold( + appBar: AppBar( + title: Text( + filePath == null || filePath.isEmpty ? 'File preview' : filePath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + actions: [ + if (canEdit) + TextButton( + onPressed: controller.isSavingFilePreview + ? null + : () { + if (_isEditing) { + _saveFile(context); + } else { + setState(() { + _isEditing = true; + _editingPath = filePath; + _editorController.text = + controller.selectedFileContent ?? ''; + }); + } + }, + child: Text(_isEditing ? 'Save' : 'Edit'), + ), + if (canEdit && _isEditing) + TextButton( + onPressed: controller.isSavingFilePreview + ? null + : () { + setState(() { + _isEditing = false; + _editorController.text = + controller.selectedFileContent ?? ''; + }); + }, + child: const Text('Cancel'), + ), + if (filePath != null && filePath.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 8), + child: controller.isFileDownloading(filePath) + ? ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: _DownloadProgressPanel( + status: controller.fileDownloadStatus(filePath), + onCancel: () => widget.onCancelDownload(filePath), + ), + ) + : OutlinedButton.icon( + onPressed: controller.selectedFileBytes == null + ? null + : () => widget.onDownload(context, filePath), + icon: const Icon(Icons.download_outlined, size: 18), + label: const Text('Download'), + ), + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 16, 16), + child: _FilePreviewBody( + controller: controller, + isEditing: _isEditing, + editorController: _editorController, + ), + ), + ), + ); + }, + ); + } +} + +class _FilePreviewBody extends StatefulWidget { + const _FilePreviewBody({ + required this.controller, + required this.isEditing, + required this.editorController, + }); + + final AppController controller; + final bool isEditing; + final TextEditingController editorController; + + @override + State<_FilePreviewBody> createState() => _FilePreviewBodyState(); +} + +class _FilePreviewBodyState extends State<_FilePreviewBody> { + static const double _lineHeight = 22; + final ScrollController _verticalController = ScrollController(); + final ScrollController _horizontalController = ScrollController(); + int? _lastScrolledLine; + + @override + void dispose() { + _verticalController.dispose(); + _horizontalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + final theme = Theme.of(context); + if (controller.isLoadingFilePreview) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.selectedFilePath == null) { + return Center( + child: Text( + 'Select a file to preview it.', + style: theme.textTheme.bodyMedium, + ), + ); + } + if (controller.selectedFileIsHumanReadable) { + if (widget.isEditing) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.filePreviewSaveError != null) ...[ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8), + child: Text( + controller.filePreviewSaveError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + Expanded( + child: TextField( + controller: widget.editorController, + expands: true, + maxLines: null, + minLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.top, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + height: 1.35, + color: theme.colorScheme.onSurface, + ), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: const EdgeInsets.fromLTRB(16, 0, 0, 0), + hintText: 'Edit file contents', + hintStyle: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ); + } + final lines = (controller.selectedFileContent ?? '').split('\n'); + final highlightedLine = controller.selectedFileHighlightedLine; + if (highlightedLine != null && + highlightedLine > 0 && + highlightedLine <= lines.length && + _lastScrolledLine != highlightedLine) { + _lastScrolledLine = highlightedLine; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_verticalController.hasClients) { + return; + } + final targetOffset = ((highlightedLine - 1) * _lineHeight) - 80; + _verticalController.animateTo( + targetOffset.clamp(0, _verticalController.position.maxScrollExtent), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + ); + }); + } + return Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (notification) => + notification.metrics.axis == Axis.horizontal, + child: SingleChildScrollView( + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: 720, + child: Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: ListView.builder( + controller: _verticalController, + itemCount: lines.length, + itemBuilder: (BuildContext context, int index) { + final lineNumber = index + 1; + final isHighlighted = highlightedLine == lineNumber; + return Container( + key: isHighlighted + ? const ValueKey('highlighted-file-line') + : null, + height: _lineHeight, + color: isHighlighted + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : null, + padding: const EdgeInsets.only(right: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 56, + child: Text( + '$lineNumber', + textAlign: TextAlign.right, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: isHighlighted + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + lines[index], + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + height: 1.3, + color: isHighlighted + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ); + } + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.insert_drive_file_outlined, + size: 36, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'This file is not previewed as text.', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Use Download to save it locally and open it with an appropriate app.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + if (controller.selectedFileBytes != null) ...[ + const SizedBox(height: 12), + Text( + '${controller.selectedFileBytes!.length} bytes', + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + ); + } +} + +class _FileEntryTile extends StatelessWidget { + const _FileEntryTile({ + required this.controller, + required this.entry, + required this.isDownloading, + required this.downloadStatus, + this.onOpenFile, + this.onDownload, + this.onCancelDownload, + }); + + final AppController controller; + final FileSystemEntry entry; + final bool isDownloading; + final FileDownloadStatus? downloadStatus; + final VoidCallback? onOpenFile; + final VoidCallback? onDownload; + final VoidCallback? onCancelDownload; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fullPath = controller.joinFileBrowserPath(entry.fileName); + final selected = controller.selectedFilePath == fullPath; + return InkWell( + onTap: () { + if (entry.isDirectory) { + controller.loadDirectory(fullPath); + } else if (entry.isFile) { + onOpenFile?.call(); + } + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + entry.isDirectory + ? Icons.folder_outlined + : Icons.description_outlined, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + entry.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + ), + if (entry.isFile && onDownload != null) ...[ + const SizedBox(width: 8), + isDownloading + ? IconButton( + tooltip: 'Cancel download', + onPressed: onCancelDownload, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 18), + ) + : IconButton( + tooltip: 'Download', + onPressed: onDownload, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.download_outlined, size: 18), + ), + ], + ], + ), + if (isDownloading && downloadStatus != null) ...[ + const SizedBox(height: 8), + _DownloadProgressDetails(status: downloadStatus!), + ], + ], + ), + ), + ); + } +} + +class _DownloadProgressPanel extends StatelessWidget { + const _DownloadProgressPanel({required this.status, required this.onCancel}); + + final FileDownloadStatus? status; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded(child: _DownloadProgressDetails(status: status)), + const SizedBox(width: 10), + IconButton( + tooltip: 'Cancel download', + onPressed: onCancel, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + ); + } +} + +class _DownloadProgressDetails extends StatelessWidget { + const _DownloadProgressDetails({required this.status}); + + final FileDownloadStatus? status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final progress = (status?.progress ?? 0).clamp(0.0, 1.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator(value: progress), + const SizedBox(height: 6), + Text(_formatTransferSize(status), style: theme.textTheme.bodySmall), + const SizedBox(height: 2), + Text( + _formatEta(status), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } +} + +String _formatTransferSize(FileDownloadStatus? status) { + final received = _formatMegabytes(status?.receivedBytes ?? 0); + final totalBytes = status?.totalBytes; + final total = totalBytes == null ? '--' : _formatMegabytes(totalBytes); + return '$received MB / $total MB'; +} + +String _formatEta(FileDownloadStatus? status) { + final eta = status?.eta; + if (eta == null) { + return 'Estimating time remaining...'; + } + if (eta == Duration.zero) { + return 'Almost done'; + } + final seconds = eta.inSeconds; + if (seconds < 60) { + return '${seconds}s remaining'; + } + final minutes = eta.inMinutes; + final remainingSeconds = seconds % 60; + if (minutes < 60) { + return '${minutes}m ${remainingSeconds}s remaining'; + } + final hours = eta.inHours; + final remainingMinutes = minutes % 60; + return '${hours}h ${remainingMinutes}m remaining'; +} + +String _formatMegabytes(int bytes) { + final megabytes = bytes / (1024 * 1024); + return megabytes.toStringAsFixed(megabytes >= 10 ? 0 : 1); +} diff --git a/lib/src/features/settings/domain/app_settings.dart b/lib/src/features/settings/domain/app_settings.dart new file mode 100644 index 0000000..4f11ed0 --- /dev/null +++ b/lib/src/features/settings/domain/app_settings.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +import '../../automations/domain/automation_models.dart'; + +enum ThemePreference { system, light, dark } + +enum SandboxMode { workspaceWrite, readOnly, dangerFullAccess } + +enum ConnectionStatus { disconnected, connecting, initializing, ready, error } + +enum ConnectionMode { direct, relay } + +enum EntryKind { user, agent, reasoning, command, fileChange, tool, system } + +const Set validApprovalPolicies = { + 'untrusted', + 'on-request', + 'on-failure', + 'never', +}; + +String normalizeApprovalPolicy(String? value) { + final raw = (value ?? '').trim(); + if (raw == 'unlessTrusted') { + return 'untrusted'; + } + if (validApprovalPolicies.contains(raw)) { + return raw; + } + return 'untrusted'; +} + +class AppSettings { + const AppSettings({ + required this.connectionMode, + required this.serverUrl, + required this.websocketBearerToken, + required this.relayUrl, + required this.relayDeviceId, + required this.relayBridgeLabel, + required this.relayBridgeSigningPublicKey, + required this.relayClientPrivateKey, + required this.relayClientPublicKey, + required this.model, + required this.reasoningEffort, + required this.planMode, + required this.approvalPolicy, + required this.sandboxMode, + required this.allowNetwork, + required this.themePreference, + required this.threadLoadTimeoutMs, + required this.resumeThreadId, + required this.favoriteThreadIds, + required this.threadDownloadDirectories, + required this.automationSnapshots, + required this.automations, + }); + + factory AppSettings.defaults() { + return const AppSettings( + connectionMode: ConnectionMode.direct, + serverUrl: 'ws://127.0.0.1:8080', + websocketBearerToken: '', + relayUrl: '', + relayDeviceId: '', + relayBridgeLabel: '', + relayBridgeSigningPublicKey: '', + relayClientPrivateKey: '', + relayClientPublicKey: '', + model: '', + reasoningEffort: 'medium', + planMode: false, + approvalPolicy: 'untrusted', + sandboxMode: SandboxMode.workspaceWrite, + allowNetwork: false, + themePreference: ThemePreference.system, + threadLoadTimeoutMs: 20000, + resumeThreadId: '', + favoriteThreadIds: [], + threadDownloadDirectories: {}, + automationSnapshots: >{}, + automations: [], + ); + } + + final ConnectionMode connectionMode; + final String serverUrl; + final String websocketBearerToken; + final String relayUrl; + final String relayDeviceId; + final String relayBridgeLabel; + final String relayBridgeSigningPublicKey; + final String relayClientPrivateKey; + final String relayClientPublicKey; + final String model; + final String reasoningEffort; + final bool planMode; + final String approvalPolicy; + final SandboxMode sandboxMode; + final bool allowNetwork; + final ThemePreference themePreference; + final int threadLoadTimeoutMs; + final String resumeThreadId; + final List favoriteThreadIds; + final Map threadDownloadDirectories; + final Map> automationSnapshots; + final List automations; + + ThemeMode get materialThemeMode { + return switch (themePreference) { + ThemePreference.system => ThemeMode.system, + ThemePreference.light => ThemeMode.light, + ThemePreference.dark => ThemeMode.dark, + }; + } + + String get activeConnectionLabel { + if (connectionMode == ConnectionMode.relay) { + final relay = relayUrl.trim(); + if (relay.isNotEmpty) { + return relay; + } + } + return serverUrl.trim(); + } + + AppSettings copyWith({ + ConnectionMode? connectionMode, + String? serverUrl, + String? websocketBearerToken, + String? relayUrl, + String? relayDeviceId, + String? relayBridgeLabel, + String? relayBridgeSigningPublicKey, + String? relayClientPrivateKey, + String? relayClientPublicKey, + String? model, + String? reasoningEffort, + bool? planMode, + String? approvalPolicy, + SandboxMode? sandboxMode, + bool? allowNetwork, + ThemePreference? themePreference, + int? threadLoadTimeoutMs, + String? resumeThreadId, + List? favoriteThreadIds, + Map? threadDownloadDirectories, + Map>? automationSnapshots, + List? automations, + }) { + return AppSettings( + connectionMode: connectionMode ?? this.connectionMode, + serverUrl: serverUrl ?? this.serverUrl, + websocketBearerToken: websocketBearerToken ?? this.websocketBearerToken, + relayUrl: relayUrl ?? this.relayUrl, + relayDeviceId: relayDeviceId ?? this.relayDeviceId, + relayBridgeLabel: relayBridgeLabel ?? this.relayBridgeLabel, + relayBridgeSigningPublicKey: + relayBridgeSigningPublicKey ?? this.relayBridgeSigningPublicKey, + relayClientPrivateKey: + relayClientPrivateKey ?? this.relayClientPrivateKey, + relayClientPublicKey: relayClientPublicKey ?? this.relayClientPublicKey, + model: model ?? this.model, + reasoningEffort: reasoningEffort ?? this.reasoningEffort, + planMode: planMode ?? this.planMode, + approvalPolicy: normalizeApprovalPolicy( + approvalPolicy ?? this.approvalPolicy, + ), + sandboxMode: sandboxMode ?? this.sandboxMode, + allowNetwork: allowNetwork ?? this.allowNetwork, + themePreference: themePreference ?? this.themePreference, + threadLoadTimeoutMs: threadLoadTimeoutMs ?? this.threadLoadTimeoutMs, + resumeThreadId: resumeThreadId ?? this.resumeThreadId, + favoriteThreadIds: favoriteThreadIds ?? this.favoriteThreadIds, + threadDownloadDirectories: + threadDownloadDirectories ?? this.threadDownloadDirectories, + automationSnapshots: automationSnapshots ?? this.automationSnapshots, + automations: automations ?? this.automations, + ); + } +} diff --git a/lib/src/features/settings/presentation/settings_page.dart b/lib/src/features/settings/presentation/settings_page.dart new file mode 100644 index 0000000..d4b559b --- /dev/null +++ b/lib/src/features/settings/presentation/settings_page.dart @@ -0,0 +1,448 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../../app_controller.dart'; +import '../../../models.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + late ConnectionMode _connectionMode; + late final TextEditingController _serverController; + late final TextEditingController _websocketBearerTokenController; + late final TextEditingController _relayUrlController; + late final TextEditingController _pairingCodeController; + late final TextEditingController _threadLoadTimeoutController; + late ThemePreference _themePreference; + late SandboxMode _sandboxMode; + late String _approvalPolicy; + late bool _allowNetwork; + bool _isPairing = false; + String? _pairingError; + String? _pairingSuccess; + + @override + void initState() { + super.initState(); + final settings = widget.controller.settings; + _connectionMode = settings.connectionMode; + _serverController = TextEditingController(text: settings.serverUrl); + _websocketBearerTokenController = TextEditingController( + text: settings.websocketBearerToken, + ); + _relayUrlController = TextEditingController(text: settings.relayUrl); + _pairingCodeController = TextEditingController(); + _threadLoadTimeoutController = TextEditingController( + text: settings.threadLoadTimeoutMs.toString(), + ); + _themePreference = settings.themePreference; + _sandboxMode = settings.sandboxMode; + _approvalPolicy = settings.approvalPolicy; + _allowNetwork = settings.allowNetwork; + } + + @override + void dispose() { + _serverController.dispose(); + _websocketBearerTokenController.dispose(); + _relayUrlController.dispose(); + _pairingCodeController.dispose(); + _threadLoadTimeoutController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: SafeArea( + top: false, + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _connectionMode, + decoration: const InputDecoration(labelText: 'Connection mode'), + items: ConnectionMode.values.map((ConnectionMode value) { + return DropdownMenuItem( + value: value, + child: Text(value.name), + ); + }).toList(), + onChanged: (ConnectionMode? value) { + if (value != null) { + setState(() { + _connectionMode = value; + }); + } + }, + ), + const SizedBox(height: 12), + if (_connectionMode == ConnectionMode.direct) ...[ + TextField( + controller: _serverController, + decoration: const InputDecoration( + labelText: 'Websocket URL', + hintText: 'ws://192.168.1.20:8080', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _websocketBearerTokenController, + autocorrect: false, + enableSuggestions: false, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Websocket bearer token', + hintText: 'Optional Authorization: Bearer token', + helperText: + 'Sent during the websocket handshake when app-server auth is enabled.', + ), + ), + ] else ...[ + TextField( + controller: _relayUrlController, + decoration: const InputDecoration( + labelText: 'Relay URL', + hintText: 'https://relay.example.com', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _pairingCodeController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Pairing code', + hintText: 'crp1....', + helperText: + 'Paste the pairing code or scan the QR shown by codex-remote-cli.', + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isPairing ? null : _scanRelayQrCode, + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan QR code'), + ), + ), + if (widget.controller.settings.relayDeviceId.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Paired bridge: ${widget.controller.settings.relayBridgeLabel.isEmpty ? widget.controller.settings.relayDeviceId : widget.controller.settings.relayBridgeLabel}', + style: theme.textTheme.bodySmall, + ), + ), + if (_pairingError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _pairingError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + if (_pairingSuccess != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _pairingSuccess!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isPairing ? null : _pairRelayDevice, + child: Text(_isPairing ? 'Pairing...' : 'Pair device'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: + widget.controller.settings.relayDeviceId.isEmpty + ? null + : _clearRelayPairing, + child: const Text('Clear pairing'), + ), + ), + ], + ), + ], + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _themePreference, + decoration: const InputDecoration(labelText: 'Theme'), + items: ThemePreference.values.map((item) { + return DropdownMenuItem( + value: item, + child: Text(item.name), + ); + }).toList(), + onChanged: (ThemePreference? value) { + if (value != null) { + setState(() => _themePreference = value); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _sandboxMode, + decoration: const InputDecoration(labelText: 'Sandbox'), + items: SandboxMode.values.map((item) { + return DropdownMenuItem( + value: item, + child: Text(item.name), + ); + }).toList(), + onChanged: (SandboxMode? value) { + if (value != null) { + setState(() => _sandboxMode = value); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _approvalPolicy, + decoration: const InputDecoration(labelText: 'Approval policy'), + items: + const [ + 'untrusted', + 'on-request', + 'on-failure', + 'never', + ].map((item) { + return DropdownMenuItem( + value: item, + child: Text(item), + ); + }).toList(), + onChanged: (String? value) { + if (value != null) { + setState(() => _approvalPolicy = value); + } + }, + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + value: _allowNetwork, + contentPadding: EdgeInsets.zero, + title: const Text('Allow network in workspace-write mode'), + onChanged: (bool value) { + setState(() => _allowNetwork = value); + }, + ), + const SizedBox(height: 12), + TextField( + controller: _threadLoadTimeoutController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Thread load timeout ms', + helperText: + 'Used for thread list, thread read, and thread resume requests.', + ), + ), + const SizedBox(height: 8), + Text( + 'Last thread: ${widget.controller.settings.resumeThreadId.isEmpty ? 'none' : widget.controller.settings.resumeThreadId}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _save, + child: const Text('Save'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _save() async { + final parsedThreadLoadTimeoutMs = int.tryParse( + _threadLoadTimeoutController.text.trim(), + ); + final nextSettings = widget.controller.settings.copyWith( + connectionMode: _connectionMode, + serverUrl: _serverController.text.trim(), + websocketBearerToken: _websocketBearerTokenController.text.trim(), + relayUrl: _relayUrlController.text.trim(), + themePreference: _themePreference, + sandboxMode: _sandboxMode, + approvalPolicy: _approvalPolicy, + allowNetwork: _allowNetwork, + threadLoadTimeoutMs: + parsedThreadLoadTimeoutMs == null || parsedThreadLoadTimeoutMs <= 0 + ? 20000 + : parsedThreadLoadTimeoutMs, + ); + await widget.controller.reconnectWithSettings(nextSettings); + if (mounted) { + Navigator.of(context).pop(); + } + } + + Future _pairRelayDevice() async { + await _pairRelayDeviceWithCode(_pairingCodeController.text); + } + + Future _pairRelayDeviceWithCode(String pairingCode) async { + setState(() { + _isPairing = true; + _pairingError = null; + _pairingSuccess = null; + }); + try { + await widget.controller.pairRelayDevice(pairingCode: pairingCode); + _relayUrlController.text = widget.controller.settings.relayUrl; + _pairingCodeController.clear(); + setState(() { + _pairingSuccess = 'Device paired successfully.'; + _connectionMode = ConnectionMode.relay; + }); + } catch (error) { + setState(() { + _pairingError = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _isPairing = false; + }); + } + } + } + + Future _scanRelayQrCode() async { + final scannedCode = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _RelayPairingQrScannerPage(), + fullscreenDialog: true, + ), + ); + if (!mounted || scannedCode == null || scannedCode.isEmpty) { + return; + } + _pairingCodeController.text = scannedCode; + await _pairRelayDeviceWithCode(scannedCode); + } + + Future _clearRelayPairing() async { + await widget.controller.clearRelayPairing(); + _relayUrlController.clear(); + setState(() { + _pairingError = null; + _pairingSuccess = null; + _connectionMode = ConnectionMode.direct; + }); + } +} + +class _RelayPairingQrScannerPage extends StatefulWidget { + const _RelayPairingQrScannerPage(); + + @override + State<_RelayPairingQrScannerPage> createState() => + _RelayPairingQrScannerPageState(); +} + +class _RelayPairingQrScannerPageState + extends State<_RelayPairingQrScannerPage> { + final MobileScannerController _controller = MobileScannerController(); + bool _handledCode = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Scan Pairing QR')), + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: (BarcodeCapture capture) { + if (_handledCode) { + return; + } + for (final barcode in capture.barcodes) { + final rawValue = barcode.rawValue?.trim() ?? ''; + if (!rawValue.startsWith('crp1.')) { + continue; + } + _handledCode = true; + _controller.stop(); + Navigator.of(context).pop(rawValue); + return; + } + }, + ), + Positioned( + left: 20, + right: 20, + bottom: 24, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Point the camera at the relay pairing QR code.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/threads/presentation/thread_history_sheet.dart b/lib/src/features/threads/presentation/thread_history_sheet.dart new file mode 100644 index 0000000..076aef6 --- /dev/null +++ b/lib/src/features/threads/presentation/thread_history_sheet.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; + +import '../../../app_controller.dart'; +import '../../../models.dart'; + +class ThreadHistorySheet extends StatelessWidget { + const ThreadHistorySheet({ + super.key, + required this.controller, + required this.onCreateThread, + }); + + final AppController controller; + final Future Function() onCreateThread; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedBuilder( + animation: controller, + builder: (BuildContext context, Widget? child) { + return Container( + margin: const EdgeInsets.only(right: 24), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(22), + ), + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom + + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Threads', + style: theme.textTheme.titleLarge, + ), + ), + IconButton( + tooltip: 'New thread', + onPressed: onCreateThread, + icon: const Icon(Icons.add), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Refresh', + onPressed: controller.isLoadingHistory + ? null + : () => controller.loadThreadHistory(reset: true), + icon: const Icon(Icons.refresh), + ), + ], + ), + if (controller.threadHistoryError != null) ...[ + const SizedBox(height: 8), + Text( + controller.threadHistoryError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.7, + child: + controller.threadHistory.isEmpty && + controller.isLoadingHistory + ? const Center(child: CircularProgressIndicator()) + : controller.threadHistory.isEmpty + ? Center( + child: Text( + 'No saved threads were returned by the server.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ) + : ListView.separated( + itemCount: + controller.threadHistory.length + + (controller.hasMoreThreadHistory ? 1 : 0), + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (BuildContext context, int index) { + if (index >= controller.threadHistory.length) { + return OutlinedButton( + onPressed: controller.isLoadingHistory + ? null + : controller.loadThreadHistory, + child: Text( + controller.isLoadingHistory + ? 'Loading' + : 'Load more', + ), + ); + } + + final thread = controller.threadHistory[index]; + return _ThreadTile( + controller: controller, + thread: thread, + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _ThreadTile extends StatelessWidget { + const _ThreadTile({required this.controller, required this.thread}); + + final AppController controller; + final ThreadSummary thread; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isOpening = controller.openingThreadId == thread.id; + final isFavorite = controller.isThreadFavorite(thread.id); + final hasActiveTurn = controller.threadHasActiveTurn(thread.id); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Row( + children: [ + if (hasActiveTurn) + Container( + key: ValueKey( + 'thread-active-turn-${thread.id}', + ), + width: 10, + height: 10, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + thread.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + ), + IconButton( + tooltip: isFavorite ? 'Unfavorite thread' : 'Favorite thread', + onPressed: () => controller.toggleFavoriteThread(thread.id), + icon: Icon( + isFavorite ? Icons.star : Icons.star_border, + color: isFavorite ? theme.colorScheme.primary : null, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + thread.preview.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (thread.updatedAt != null) + _ThreadMeta( + label: 'Updated', + value: _formatDate(thread.updatedAt!), + ), + if (thread.cwd.isNotEmpty) + _ThreadMeta(label: 'Cwd', value: thread.cwd), + if (thread.agentNickname != null && + thread.agentNickname!.isNotEmpty) + _ThreadMeta(label: 'Agent', value: thread.agentNickname!), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isOpening + ? null + : () async { + await controller.resumeThreadFromHistory(thread.id); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text(isOpening ? 'Opening' : 'Open thread'), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime value) { + final local = value; + return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}'; + } + + String two(int n) { + return n.toString().padLeft(2, '0'); + } +} + +class _ThreadMeta extends StatelessWidget { + const _ThreadMeta({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$label: $value', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ); + } +} diff --git a/lib/src/features/workspace/domain/workspace_models.dart b/lib/src/features/workspace/domain/workspace_models.dart new file mode 100644 index 0000000..57b77d6 --- /dev/null +++ b/lib/src/features/workspace/domain/workspace_models.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../../settings/domain/app_settings.dart'; + +class ActivityEntry { + ActivityEntry({ + required this.key, + required this.kind, + required this.title, + this.body = '', + this.secondary = '', + this.status = '', + this.isStreaming = false, + this.isLocalPending = false, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + final String key; + final EntryKind kind; + final DateTime timestamp; + String title; + String body; + String secondary; + String status; + bool isStreaming; + bool isLocalPending; +} + +class PendingApproval { + PendingApproval({ + required this.requestId, + required this.method, + required this.itemId, + required this.title, + required this.detail, + required this.availableDecisions, + }); + + final int requestId; + final String method; + final String itemId; + final String title; + final String detail; + final List availableDecisions; +} + +class EventLogEntry { + EventLogEntry(this.method, this.summary) : timestamp = DateTime.now(); + + final String method; + final String summary; + final DateTime timestamp; +} + +class ThreadSummary { + const ThreadSummary({ + required this.id, + required this.preview, + required this.cwd, + required this.source, + required this.modelProvider, + required this.createdAt, + required this.updatedAt, + required this.status, + this.name, + this.agentNickname, + this.agentRole, + }); + + final String id; + final String preview; + final String cwd; + final String source; + final String modelProvider; + final DateTime? createdAt; + final DateTime? updatedAt; + final String status; + final String? name; + final String? agentNickname; + final String? agentRole; + + String get title { + final named = name?.trim() ?? ''; + if (named.isNotEmpty) { + return named; + } + final trimmed = preview.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + return 'Untitled thread'; + } +} + +class FileSystemEntry { + const FileSystemEntry({ + required this.fileName, + required this.isDirectory, + required this.isFile, + }); + + final String fileName; + final bool isDirectory; + final bool isFile; +} + +class ModelOption { + const ModelOption({ + required this.id, + required this.model, + required this.displayName, + required this.description, + required this.isDefault, + required this.hidden, + }); + + final String id; + final String model; + final String displayName; + final String description; + final bool isDefault; + final bool hidden; +} + +enum PendingPromptMode { queued, steer } + +class PendingPrompt { + const PendingPrompt({ + required this.id, + required this.text, + required this.mode, + this.attachments = const [], + }); + + final String id; + final String text; + final PendingPromptMode mode; + final List attachments; + + PendingPrompt copyWith({ + String? id, + String? text, + PendingPromptMode? mode, + List? attachments, + }) { + return PendingPrompt( + id: id ?? this.id, + text: text ?? this.text, + mode: mode ?? this.mode, + attachments: attachments ?? this.attachments, + ); + } +} + +enum ComposerAttachmentKind { textFile, image } + +class ComposerAttachment { + const ComposerAttachment({ + required this.id, + required this.fileName, + required this.kind, + required this.bytes, + this.mimeType, + this.textContent, + }); + + final String id; + final String fileName; + final ComposerAttachmentKind kind; + final Uint8List bytes; + final String? mimeType; + final String? textContent; + + bool get isImage => kind == ComposerAttachmentKind.image; + bool get isTextFile => kind == ComposerAttachmentKind.textFile; + + String? get dataUrl { + final type = mimeType; + if (type == null || type.isEmpty) { + return null; + } + return 'data:$type;base64,${base64Encode(bytes)}'; + } +} + +bool isLikelyHumanReadableFile(String path, Uint8List bytes) { + const textExtensions = { + 'txt', + 'md', + 'markdown', + 'json', + 'yaml', + 'yml', + 'toml', + 'xml', + 'html', + 'css', + 'js', + 'ts', + 'tsx', + 'jsx', + 'dart', + 'kt', + 'java', + 'swift', + 'm', + 'mm', + 'c', + 'cc', + 'cpp', + 'h', + 'hpp', + 'rs', + 'go', + 'py', + 'rb', + 'php', + 'sh', + 'zsh', + 'bash', + 'fish', + 'sql', + 'csv', + 'log', + 'ini', + 'cfg', + 'conf', + 'env', + 'gitignore', + 'pubspec', + 'lock', + }; + + final segments = path.split('/'); + final fileName = segments.isEmpty ? path : segments.last; + final extension = fileName.contains('.') + ? fileName.split('.').last.toLowerCase() + : fileName.toLowerCase(); + if (textExtensions.contains(extension)) { + return true; + } + + if (bytes.isEmpty) { + return true; + } + + var suspicious = 0; + for (final byte in bytes.take(512)) { + if (byte == 0) { + return false; + } + if (byte < 9 || (byte > 13 && byte < 32)) { + suspicious += 1; + } + } + return suspicious < 12; +} diff --git a/lib/src/features/workspace/presentation/home_page.dart b/lib/src/features/workspace/presentation/home_page.dart new file mode 100644 index 0000000..ce1ab7a --- /dev/null +++ b/lib/src/features/workspace/presentation/home_page.dart @@ -0,0 +1,2933 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter/services.dart'; +import 'package:image/image.dart' as img; +import 'package:open_filex/open_filex.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:super_clipboard/super_clipboard.dart'; + +import '../../../app_controller.dart'; +import '../../../models.dart'; +import '../../automations/presentation/automation_pages.dart'; +import '../../commands/presentation/command_center_page.dart'; +import '../../downloads/presentation/download_center_page.dart'; +import '../../files/presentation/file_pages.dart'; +import '../../settings/presentation/settings_page.dart'; +import '../../threads/presentation/thread_history_sheet.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _HomePageState(); +} + +Future openDownloadedLocation( + BuildContext context, + String savedPath, +) async { + if (Platform.isAndroid) { + final currentStatus = await Permission.manageExternalStorage.status; + if (!currentStatus.isGranted) { + final requested = await Permission.manageExternalStorage.request(); + if (!requested.isGranted) { + await openAppSettings(); + if (!context.mounted) { + return; + } + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + 'Allow All files access for Codex Remote to open downloaded locations.', + ), + ), + ); + return; + } + } + } + final parentPath = File(savedPath).parent.path; + var result = await OpenFilex.open(parentPath); + if (result.type != ResultType.done) { + result = await OpenFilex.open(savedPath); + } + if (result.type == ResultType.done || !context.mounted) { + return; + } + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + result.message.isNotEmpty + ? result.message + : 'Unable to open the downloaded file location.', + ), + ), + ); +} + +class _HomePageState extends State with TickerProviderStateMixin { + static const int _maxImageAttachmentBytes = 2 * 1024 * 1024; + static const int _maxImageAttachmentDimension = 1600; + final TextEditingController _composerController = TextEditingController(); + final List _composerAttachments = []; + final FocusNode _composerFocusNode = FocusNode(); + late final AnimationController _downloadPulseController; + late final AnimationController _downloadPopController; + int _previousActiveDownloadCount = 0; + int _previousDownloadCount = 0; + bool _showActionBar = true; + bool _isOpeningThreadHistory = false; + + @override + void initState() { + super.initState(); + ClipboardEvents.instance?.registerPasteEventListener(_onPasteEvent); + _downloadPulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _downloadPopController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + widget.controller.addListener(_handleControllerChanged); + _previousActiveDownloadCount = widget.controller.activeDownloadCount; + _previousDownloadCount = widget.controller.downloadRecords.length; + _syncDownloadAnimations(); + } + + @override + void dispose() { + widget.controller.removeListener(_handleControllerChanged); + ClipboardEvents.instance?.unregisterPasteEventListener(_onPasteEvent); + _composerController.dispose(); + _composerFocusNode.dispose(); + _downloadPulseController.dispose(); + _downloadPopController.dispose(); + super.dispose(); + } + + Future _showRateLimitMenu( + BuildContext context, + AppController controller, + ) async { + if (!controller.hasRateLimitResetDetails) { + return; + } + final box = context.findRenderObject(); + final overlay = Overlay.of(context).context.findRenderObject(); + if (box is! RenderBox || overlay is! RenderBox) { + return; + } + final topLeft = box.localToGlobal(Offset.zero, ancestor: overlay); + final bottomRight = box.localToGlobal( + box.size.bottomRight(Offset.zero), + ancestor: overlay, + ); + final position = RelativeRect.fromRect( + Rect.fromPoints(topLeft, bottomRight), + Offset.zero & overlay.size, + ); + await showMenu( + context: context, + position: position, + items: controller.rateLimitResetDetails + .map( + (detail) => PopupMenuItem( + enabled: false, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Text(detail), + ), + ) + .toList(), + ); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + final theme = Theme.of(context); + + return AnimatedBuilder( + animation: controller, + builder: (BuildContext context, Widget? child) { + final isConnecting = + controller.status == ConnectionStatus.connecting || + controller.status == ConnectionStatus.initializing; + return Scaffold( + appBar: AppBar( + titleSpacing: 12, + title: _TopBarTitle( + controller: controller, + onRenameActiveThread: () => _promptRenameActiveThread(context), + ), + actions: [ + IconButton( + tooltip: _showActionBar ? 'Hide actions' : 'Show actions', + onPressed: () { + setState(() { + _showActionBar = !_showActionBar; + }); + }, + icon: Icon( + _showActionBar + ? Icons.arrow_drop_up_rounded + : Icons.arrow_drop_down_rounded, + size: 30, + ), + ), + IconButton( + tooltip: 'Settings', + onPressed: () => _openSettings(context), + icon: const Icon(Icons.settings_outlined), + ), + ], + ), + body: Stack( + children: [ + SafeArea( + top: false, + child: Column( + children: [ + AnimatedCrossFade( + duration: const Duration(milliseconds: 180), + crossFadeState: _showActionBar + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: _ActionBar( + controller: controller, + pulse: _downloadPulseController, + pop: _downloadPopController, + onOpenThreads: () => _openThreadHistory(context), + onOpenFiles: () => _openFiles(context), + onOpenCommands: () => _openCommandCenter(context), + onOpenAutomations: () => _openAutomations(context), + onToggleConnection: () async { + if (controller.isConnected) { + await controller.disconnect(); + } else { + await controller.connect(); + } + }, + onOpenDownloads: () => _openDownloadCenter(context), + ), + secondChild: Container( + width: double.infinity, + height: 0, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor), + ), + ), + ), + ), + if (controller.approvals.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _ApprovalPanel(controller: controller), + ), + Expanded( + child: controller.entries.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Connect to a Codex app-server, then send a prompt. Command output, file changes, approvals, and automations will appear in the same timeline.', + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : ListView.separated( + reverse: true, + padding: const EdgeInsets.fromLTRB( + 16, + 18, + 16, + 24, + ), + itemBuilder: (BuildContext context, int index) { + final entry = + controller.entries[controller + .entries + .length - + 1 - + index]; + return _EntryTile( + entry: entry, + onEditMessage: _editTimelineMessage, + onOpenFileReference: _openFileReference, + ); + }, + separatorBuilder: (_, _) => + const SizedBox(height: 12), + itemCount: controller.entries.length, + ), + ), + Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor), + ), + ), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + child: Column( + children: [ + if (controller.queuedPromptCount > 0) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _QueuedPromptBar( + controller: controller, + onEditPrompt: _editPendingPrompt, + onPromotePrompt: widget + .controller + .promotePendingPromptToSteer, + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FooterIconButton( + tooltip: 'Attach', + icon: Icons.attach_file_outlined, + onPressed: _pickComposerAttachments, + ), + const SizedBox(width: 8), + _FooterActionButton( + label: controller.settings.planMode + ? 'Plan on' + : 'Plan off', + icon: Icons.route_outlined, + onPressed: () => _togglePlanMode(controller), + ), + const SizedBox(width: 8), + _FooterActionButton( + label: _modelLabel(controller), + icon: Icons.tune_outlined, + onPressed: () => + _editModel(context, controller), + ), + const SizedBox(width: 8), + _FooterActionButton( + label: controller.settings.reasoningEffort, + icon: Icons.psychology_alt_outlined, + onPressed: () => + _pickReasoningEffort(context, controller), + ), + if (controller.hasActiveTurn) ...[ + const SizedBox(width: 8), + _FooterIconButton( + tooltip: 'Stop', + icon: Icons.stop_circle_outlined, + onPressed: controller.interruptTurn, + ), + ], + ], + ), + ), + const SizedBox(height: 8), + if (_composerAttachments.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ComposerAttachmentBar( + attachments: _composerAttachments, + onRemove: _removeComposerAttachment, + ), + ), + TextField( + controller: _composerController, + focusNode: _composerFocusNode, + minLines: 1, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + hintText: 'Message Codex...', + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.hasActiveTurn) ...[ + OutlinedButton( + onPressed: controller.isSteering + ? null + : () => _steerPrompt(controller), + child: Text( + controller.isSteering + ? 'Steering...' + : 'Steer', + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: () => _sendPrompt(controller), + child: Text( + controller.hasActiveTurn + ? 'Queue' + : 'Send', + ), + ), + if (controller.composerMetaLeftText != + null || + controller.composerMetaRightText != + null) ...[ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Builder( + builder: (BuildContext context) { + final text = Text( + controller + .composerMetaLeftText ?? + '', + key: const ValueKey( + 'composer-meta-left-text', + ), + maxLines: 1, + overflow: + TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: theme + .textTheme + .bodySmall + ?.copyWith( + fontSize: 10, + height: 1.1, + color: theme + .colorScheme + .onSurfaceVariant, + ), + ); + if (!controller + .hasRateLimitResetDetails) { + return text; + } + return InkWell( + key: const ValueKey( + 'composer-meta-left-button', + ), + borderRadius: + BorderRadius.circular(6), + onTap: () => + _showRateLimitMenu( + context, + controller, + ), + child: Padding( + padding: + const EdgeInsets.symmetric( + vertical: 2, + ), + child: text, + ), + ); + }, + ), + ), + if (controller + .composerMetaRightText != + null && + controller + .composerMetaRightText! + .isNotEmpty) ...[ + const SizedBox(width: 8), + if (controller + .contextUsagePercent != + null) + Row( + key: const ValueKey( + 'composer-meta-right-indicator', + ), + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + value: + controller + .contextUsagePercent! / + 100, + strokeWidth: 2, + backgroundColor: theme + .colorScheme + .surfaceContainerHighest, + valueColor: + AlwaysStoppedAnimation< + Color + >( + theme + .colorScheme + .primary, + ), + ), + ), + const SizedBox(width: 4), + Text( + '${controller.contextUsagePercent!.toString().padLeft(2, '0')}%', + key: const ValueKey( + 'composer-meta-right-percent', + ), + maxLines: 1, + overflow: + TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme + .textTheme + .bodySmall + ?.copyWith( + fontSize: 10, + height: 1.1, + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ) + else + Text( + controller + .composerMetaRightText!, + key: const ValueKey( + 'composer-meta-right-text', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme.textTheme.bodySmall + ?.copyWith( + fontSize: 10, + height: 1.1, + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (isConnecting) + Positioned.fill( + child: AbsorbPointer( + child: Container( + key: const ValueKey('connection-overlay'), + color: theme.colorScheme.scrim.withValues(alpha: 0.24), + child: const Center( + child: CircularProgressIndicator( + key: ValueKey('connection-overlay-spinner'), + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Future _sendPrompt(AppController controller) async { + if (!(await _ensureThreadDirectorySelected(controller))) { + return; + } + final prompt = _composerController.text; + final attachments = List.from(_composerAttachments); + _composerController.clear(); + setState(() { + _composerAttachments.clear(); + }); + await controller.sendPrompt(prompt, attachments: attachments); + } + + Future _ensureThreadDirectorySelected(AppController controller) async { + if (!controller.needsThreadDirectorySelection) { + return true; + } + final directory = await _pickThreadDirectory(controller); + if (directory == null || directory.isEmpty) { + return false; + } + await controller.startFreshThreadInDirectory(directory); + return true; + } + + Future _createThreadWithDirectory(AppController controller) async { + final directory = await _pickThreadDirectory(controller); + if (directory == null || directory.isEmpty) { + return; + } + await controller.startFreshThreadInDirectory(directory); + } + + Future _pickThreadDirectory(AppController controller) async { + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return AutomationPathPickerPage( + controller: controller, + allowDirectorySelection: true, + allowFileSelection: false, + title: 'Select thread folder', + initialPath: controller.preferredFileBrowserRoot, + ); + }, + ), + ); + } + + Future _steerPrompt(AppController controller) async { + final prompt = _composerController.text; + if (prompt.trim().isEmpty && _composerAttachments.isEmpty) { + return; + } + final attachments = List.from(_composerAttachments); + final accepted = await controller.steerPrompt( + prompt, + attachments: attachments, + ); + if (accepted) { + _composerController.clear(); + setState(() { + _composerAttachments.clear(); + }); + } + } + + void _editTimelineMessage(ActivityEntry entry) { + final content = (entry.body.isEmpty ? entry.title : entry.body).trim(); + if (content.isEmpty) { + return; + } + _composerController + ..text = content + ..selection = TextSelection.collapsed(offset: content.length); + } + + void _dismissComposerFocus() { + _composerFocusNode.unfocus(); + FocusManager.instance.primaryFocus?.unfocus(); + } + + Future _openFileReference(String path, {int? line}) async { + final resolvedPath = widget.controller.resolveFileReferencePath(path); + if (resolvedPath == null || resolvedPath.isEmpty) { + return; + } + _dismissComposerFocus(); + await widget.controller.openFile(resolvedPath, highlightedLine: line); + if (!mounted) { + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return FilePreviewPage( + controller: widget.controller, + onDownload: _downloadPreviewFile, + onCancelDownload: _cancelPreviewDownload, + ); + }, + ), + ); + } + + Future _downloadPreviewFile( + BuildContext context, + String filePath, + ) async { + try { + await widget.controller.saveFileToDevice(filePath); + } catch (error) { + // Download errors are surfaced in the download center. + } + } + + Future _cancelPreviewDownload(String filePath) async { + await widget.controller.cancelFileDownload(filePath); + } + + Future _promptRenameActiveThread(BuildContext context) async { + final threadId = widget.controller.activeThreadId?.trim() ?? ''; + if (threadId.isEmpty) { + return; + } + _dismissComposerFocus(); + final textController = TextEditingController( + text: widget.controller.activeThreadName?.trim() ?? '', + ); + final nextName = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Rename thread'), + content: TextField( + controller: textController, + autofocus: true, + decoration: const InputDecoration(labelText: 'Thread name'), + onSubmitted: (value) => Navigator.of(context).pop(value.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => + Navigator.of(context).pop(textController.text.trim()), + child: const Text('Save'), + ), + ], + ); + }, + ); + if (nextName == null) { + return; + } + await widget.controller.renameThread(threadId, nextName); + } + + Future _openDownloadCenter(BuildContext context) async { + _dismissComposerFocus(); + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return DownloadCenterPage(controller: widget.controller); + }, + ), + ); + } + + void _handleControllerChanged() { + final activeCount = widget.controller.activeDownloadCount; + final totalCount = widget.controller.downloadRecords.length; + if (activeCount != _previousActiveDownloadCount || + totalCount != _previousDownloadCount) { + _downloadPopController.forward(from: 0); + _previousActiveDownloadCount = activeCount; + _previousDownloadCount = totalCount; + _syncDownloadAnimations(); + } + } + + void _syncDownloadAnimations() { + if (widget.controller.activeDownloadCount > 0) { + if (!_downloadPulseController.isAnimating) { + _downloadPulseController.repeat(reverse: true); + } + } else { + _downloadPulseController.stop(); + _downloadPulseController.value = 0; + } + } + + void _editPendingPrompt(String pendingId) { + final value = widget.controller.takePendingPromptForEditing(pendingId); + if (value == null) { + return; + } + _composerController.text = value.text; + _composerController.selection = TextSelection.collapsed( + offset: value.text.length, + ); + setState(() { + _composerAttachments + ..clear() + ..addAll(value.attachments); + }); + } + + Future _pickComposerAttachments() async { + _dismissComposerFocus(); + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + withData: true, + ); + if (result == null || !mounted) { + return; + } + final nextAttachments = []; + final rejected = []; + for (final file in result.files) { + final bytes = + file.bytes ?? + (file.path == null ? null : await File(file.path!).readAsBytes()); + final name = file.name.trim(); + if (bytes == null || name.isEmpty) { + continue; + } + final attachment = await _attachmentFromBytes( + fileName: name, + bytes: bytes, + mimeType: file.extension == null ? null : _mimeTypeForFileName(name), + ); + if (attachment == null) { + rejected.add(name); + } else { + nextAttachments.add(attachment); + } + } + if (nextAttachments.isNotEmpty) { + setState(() { + _composerAttachments.addAll(nextAttachments); + }); + } + if (rejected.isNotEmpty && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Unsupported attachments: ${rejected.join(', ')}'), + ), + ); + } + } + + Future _onPasteEvent(ClipboardReadEvent event) async { + final reader = await event.getClipboardReader(); + await _handleClipboardReader(reader); + } + + Future _handleClipboardReader(ClipboardReader reader) async { + final attachment = await _readImageAttachmentFromClipboard(reader); + if (!mounted || attachment == null) { + return; + } + setState(() { + _composerAttachments.add(attachment); + }); + } + + Future _readImageAttachmentFromClipboard( + ClipboardReader reader, + ) async { + for (final item in reader.items) { + final png = await _readClipboardFile(item, Formats.png); + if (png != null) { + return _imageAttachment( + fileName: await item.getSuggestedName() ?? 'Pasted Image.png', + bytes: png, + mimeType: 'image/png', + ); + } + final jpeg = await _readClipboardFile(item, Formats.jpeg); + if (jpeg != null) { + return _imageAttachment( + fileName: await item.getSuggestedName() ?? 'Pasted Image.jpg', + bytes: jpeg, + mimeType: 'image/jpeg', + ); + } + final gif = await _readClipboardFile(item, Formats.gif); + if (gif != null) { + return _imageAttachment( + fileName: await item.getSuggestedName() ?? 'Pasted Image.gif', + bytes: gif, + mimeType: 'image/gif', + ); + } + final webp = await _readClipboardFile(item, Formats.webp); + if (webp != null) { + return _imageAttachment( + fileName: await item.getSuggestedName() ?? 'Pasted Image.webp', + bytes: webp, + mimeType: 'image/webp', + ); + } + } + return null; + } + + Future _readClipboardFile( + DataReader reader, + FileFormat format, + ) async { + final completer = Completer(); + final progress = reader.getFile( + format, + (DataReaderFile file) async { + try { + completer.complete(await file.readAll()); + } catch (error) { + completer.completeError(error); + } + }, + onError: (Object error) { + completer.completeError(error); + }, + ); + if (progress == null) { + return null; + } + return completer.future; + } + + Future _attachmentFromBytes({ + required String fileName, + required Uint8List bytes, + String? mimeType, + }) async { + if (_isImageFile(fileName, mimeType)) { + return _imageAttachment( + fileName: fileName, + bytes: bytes, + mimeType: mimeType ?? _mimeTypeForFileName(fileName) ?? 'image/png', + ); + } + if (!isLikelyHumanReadableFile(fileName, bytes)) { + return null; + } + return ComposerAttachment( + id: 'attachment-${DateTime.now().microsecondsSinceEpoch}-$fileName', + fileName: fileName, + kind: ComposerAttachmentKind.textFile, + bytes: bytes, + mimeType: mimeType, + textContent: String.fromCharCodes(bytes), + ); + } + + Future _imageAttachment({ + required String fileName, + required Uint8List bytes, + required String mimeType, + }) async { + final prepared = _prepareImageAttachment( + fileName: fileName, + bytes: bytes, + mimeType: mimeType, + ); + return ComposerAttachment( + id: 'attachment-${DateTime.now().microsecondsSinceEpoch}-${prepared.fileName}', + fileName: prepared.fileName, + kind: ComposerAttachmentKind.image, + bytes: prepared.bytes, + mimeType: prepared.mimeType, + ); + } + + _PreparedImageAttachment _prepareImageAttachment({ + required String fileName, + required Uint8List bytes, + required String mimeType, + }) { + final decoded = img.decodeImage(bytes); + if (decoded == null) { + return _PreparedImageAttachment( + fileName: fileName, + bytes: bytes, + mimeType: mimeType, + ); + } + final longestSide = decoded.width > decoded.height + ? decoded.width + : decoded.height; + final shouldResize = longestSide > _maxImageAttachmentDimension; + final shouldReencode = + shouldResize || + bytes.length > _maxImageAttachmentBytes || + mimeType == 'image/heic' || + mimeType == 'image/heif' || + mimeType == 'image/bmp' || + mimeType == 'image/gif' || + mimeType == 'image/webp'; + if (!shouldReencode) { + return _PreparedImageAttachment( + fileName: fileName, + bytes: bytes, + mimeType: mimeType, + ); + } + + img.Image output = decoded; + if (shouldResize) { + if (decoded.width >= decoded.height) { + output = img.copyResize(decoded, width: _maxImageAttachmentDimension); + } else { + output = img.copyResize(decoded, height: _maxImageAttachmentDimension); + } + } + + var quality = 88; + var encoded = Uint8List.fromList(img.encodeJpg(output, quality: quality)); + while (encoded.length > _maxImageAttachmentBytes && quality > 52) { + quality -= 12; + encoded = Uint8List.fromList(img.encodeJpg(output, quality: quality)); + } + return _PreparedImageAttachment( + fileName: _replaceFileExtension(fileName, 'jpg'), + bytes: encoded, + mimeType: 'image/jpeg', + ); + } + + String _replaceFileExtension(String fileName, String extension) { + final dotIndex = fileName.lastIndexOf('.'); + final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); + return '$baseName.$extension'; + } + + bool _isImageFile(String fileName, String? mimeType) { + final type = (mimeType ?? '').toLowerCase(); + if (type.startsWith('image/')) { + return true; + } + final extension = fileName.contains('.') + ? fileName.split('.').last.toLowerCase() + : ''; + return { + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'bmp', + 'heic', + 'heif', + }.contains(extension); + } + + String? _mimeTypeForFileName(String fileName) { + final extension = fileName.contains('.') + ? fileName.split('.').last.toLowerCase() + : ''; + return switch (extension) { + 'png' => 'image/png', + 'jpg' || 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'bmp' => 'image/bmp', + 'heic' => 'image/heic', + 'heif' => 'image/heif', + _ => null, + }; + } + + void _removeComposerAttachment(String id) { + setState(() { + _composerAttachments.removeWhere((item) => item.id == id); + }); + } + + Future _openSettings(BuildContext context) async { + _dismissComposerFocus(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return SettingsPage(controller: widget.controller); + }, + ), + ); + } + + Future _openAutomations(BuildContext context) async { + _dismissComposerFocus(); + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return AutomationPage(controller: widget.controller); + }, + ), + ); + } + + Future _openThreadHistory(BuildContext context) async { + if (_isOpeningThreadHistory) { + return; + } + _isOpeningThreadHistory = true; + _dismissComposerFocus(); + try { + await widget.controller.loadThreadHistory(reset: true); + if (!context.mounted) { + return; + } + await showGeneralDialog( + context: context, + barrierLabel: 'Threads', + barrierDismissible: true, + barrierColor: Colors.black54, + pageBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Align( + alignment: Alignment.centerLeft, + child: Material( + color: Colors.transparent, + child: SizedBox( + width: MediaQuery.sizeOf(context).width * 0.88, + child: ThreadHistorySheet( + controller: widget.controller, + onCreateThread: () => + _createThreadWithDirectory(widget.controller), + ), + ), + ), + ); + }, + transitionBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + return SlideTransition( + position: Tween( + begin: const Offset(-1, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ); + }, + ); + } finally { + _isOpeningThreadHistory = false; + } + } + + Future _openFiles(BuildContext context) async { + _dismissComposerFocus(); + await widget.controller.openFileBrowser(); + if (!context.mounted) { + return; + } + await showGeneralDialog( + context: context, + barrierLabel: 'Files', + barrierDismissible: true, + barrierColor: Colors.black54, + pageBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Align( + alignment: Alignment.centerLeft, + child: Material( + color: Colors.transparent, + child: SizedBox( + width: MediaQuery.sizeOf(context).width * 0.92, + child: FileBrowserSheet(controller: widget.controller), + ), + ), + ); + }, + transitionBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + return SlideTransition( + position: Tween( + begin: const Offset(-1, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ); + }, + ); + } + + Future _openCommandCenter(BuildContext context) async { + _dismissComposerFocus(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return CommandCenterPage(controller: widget.controller); + }, + ), + ); + } + + Future _pickReasoningEffort( + BuildContext context, + AppController controller, + ) async { + _dismissComposerFocus(); + final selected = await showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return SafeArea( + top: false, + child: Wrap( + children: ['low', 'medium', 'high', 'xhigh'] + .map( + (item) => ListTile( + title: Text(item), + onTap: () => Navigator.of(context).pop(item), + ), + ) + .toList(), + ), + ); + }, + ); + if (selected == null) { + return; + } + await controller.saveSettings( + controller.settings.copyWith(reasoningEffort: selected), + ); + } + + Future _editModel( + BuildContext context, + AppController controller, + ) async { + _dismissComposerFocus(); + await controller.loadModelOptions(force: true); + if (!context.mounted) { + return; + } + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return SafeArea( + top: false, + child: AnimatedBuilder( + animation: controller, + builder: (BuildContext context, Widget? child) { + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Model', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + if (controller.modelListError != null) + Text( + controller.modelListError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + if (controller.isLoadingModels) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + ListTile( + title: const Text('Default model'), + subtitle: const Text( + 'Use the server default selection', + ), + selected: controller.settings.model + .trim() + .isEmpty, + onTap: () => Navigator.of(context).pop(''), + ), + ...controller.modelOptions.map((option) { + final value = option.model.trim(); + return ListTile( + title: Text( + option.displayName.isEmpty + ? value + : option.displayName, + ), + subtitle: option.description.isEmpty + ? null + : Text(option.description), + selected: + value.isNotEmpty && + controller.settings.model.trim() == value, + trailing: option.isDefault + ? const Text('Default') + : null, + onTap: () => Navigator.of(context).pop(value), + ); + }), + ], + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + if (selected == null) { + return; + } + await controller.saveSettings( + controller.settings.copyWith(model: selected), + ); + } + + Future _togglePlanMode(AppController controller) async { + await controller.saveSettings( + controller.settings.copyWith(planMode: !controller.settings.planMode), + ); + } + + String _modelLabel(AppController controller) { + final model = controller.settings.model.trim(); + if (model.isNotEmpty) { + return model; + } + final defaultOption = controller.modelOptions + .cast() + .firstWhere((option) => option?.isDefault == true, orElse: () => null); + if (defaultOption == null) { + return 'Server default'; + } + final displayName = defaultOption.displayName.trim(); + if (displayName.isNotEmpty) { + return displayName; + } + final defaultModel = defaultOption.model.trim(); + return defaultModel.isEmpty ? 'Server default' : defaultModel; + } +} + +class _PreparedImageAttachment { + const _PreparedImageAttachment({ + required this.fileName, + required this.bytes, + required this.mimeType, + }); + + final String fileName; + final Uint8List bytes; + final String mimeType; +} + +class _TopBarTitle extends StatelessWidget { + const _TopBarTitle({ + required this.controller, + required this.onRenameActiveThread, + }); + + final AppController controller; + final VoidCallback onRenameActiveThread; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: controller.activeThreadId != null ? onRenameActiveThread : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _titleText(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + _subtitleText(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + String _titleText() { + final activeName = controller.activeThreadName?.trim() ?? ''; + if (activeName.isNotEmpty) { + return activeName; + } + return 'Codex Remote'; + } + + String _subtitleText() { + if (controller.activeThreadCwd.trim().isNotEmpty) { + return controller.activeThreadCwd.trim(); + } + if (controller.settings.connectionMode == ConnectionMode.relay) { + final bridgeLabel = controller.settings.relayBridgeLabel.trim(); + if (bridgeLabel.isNotEmpty) { + return bridgeLabel; + } + if (controller.settings.relayUrl.trim().isNotEmpty) { + return controller.settings.relayUrl.trim(); + } + } + return controller.settings.serverUrl; + } +} + +class _ActionBar extends StatelessWidget { + const _ActionBar({ + required this.controller, + required this.pulse, + required this.pop, + required this.onOpenThreads, + required this.onOpenFiles, + required this.onOpenCommands, + required this.onOpenAutomations, + required this.onToggleConnection, + required this.onOpenDownloads, + }); + + final AppController controller; + final Animation pulse; + final Animation pop; + final VoidCallback onOpenThreads; + final VoidCallback onOpenFiles; + final VoidCallback onOpenCommands; + final VoidCallback onOpenAutomations; + final VoidCallback onToggleConnection; + final VoidCallback onOpenDownloads; + + Widget _animatedActionIcon({required Widget icon, required bool animate}) { + if (!animate) { + return icon; + } + return AnimatedBuilder( + animation: pulse, + builder: (BuildContext context, Widget? child) { + final scale = 1 + (pulse.value * 0.05); + return Transform.scale(scale: scale, child: child); + }, + child: icon, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeCount = controller.activeDownloadCount; + final totalDownloads = controller.downloadRecords.length; + final hasRunningAutomation = controller.automations.any( + (item) => controller.isAutomationRunning(item.id), + ); + final buttonStyle = IconButton.styleFrom( + visualDensity: const VisualDensity(horizontal: -0.5, vertical: -0.5), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + minimumSize: const Size(56, 50), + tapTargetSize: MaterialTapTargetSize.padded, + alignment: Alignment.centerLeft, + ); + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide(color: theme.dividerColor), + bottom: BorderSide(color: theme.dividerColor), + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Threads', + style: buttonStyle, + onPressed: controller.isLoadingHistory ? null : onOpenThreads, + iconSize: 26, + icon: controller.isLoadingHistory + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.2), + ) + : const Icon(Icons.menu, size: 26), + ), + IconButton( + tooltip: controller.isConnected ? 'Disconnect' : 'Connect', + style: buttonStyle, + onPressed: onToggleConnection, + iconSize: 26, + icon: Icon( + controller.isConnected ? Icons.link_off : Icons.link, + size: 26, + ), + ), + IconButton( + tooltip: 'Command', + style: buttonStyle, + onPressed: onOpenCommands, + iconSize: 26, + icon: const Icon(Icons.terminal, size: 26), + ), + IconButton( + tooltip: 'Files', + style: buttonStyle, + onPressed: onOpenFiles, + iconSize: 26, + icon: const Icon(Icons.folder_outlined, size: 26), + ), + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + tooltip: 'Automations', + style: buttonStyle, + onPressed: onOpenAutomations, + iconSize: 26, + icon: _animatedActionIcon( + animate: hasRunningAutomation, + icon: const Icon(Icons.account_tree_outlined, size: 26), + ), + ), + if (hasRunningAutomation) + Positioned( + top: 6, + right: 8, + child: SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + key: const ValueKey( + 'automation-running-indicator', + ), + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ), + ), + ], + ), + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + tooltip: 'Downloads', + style: buttonStyle, + onPressed: onOpenDownloads, + iconSize: 26, + icon: _animatedActionIcon( + animate: activeCount > 0, + icon: Icon( + activeCount > 0 + ? Icons.downloading_rounded + : Icons.download_outlined, + size: 26, + ), + ), + ), + if (totalDownloads > 0) + Positioned( + top: 3, + right: 3, + child: SizedBox( + width: 22, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1.5, + ), + decoration: BoxDecoration( + color: activeCount > 0 + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + activeCount > 0 + ? '$activeCount' + : '$totalDownloads', + style: theme.textTheme.labelSmall?.copyWith( + color: activeCount > 0 + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _FooterActionButton extends StatelessWidget { + const _FooterActionButton({ + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ); + } +} + +class _FooterIconButton extends StatelessWidget { + const _FooterIconButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Tooltip( + message: tooltip, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + minimumSize: const Size(44, 40), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + ), + child: Icon(icon, size: 18, color: theme.colorScheme.onSurface), + ), + ); + } +} + +class _ApprovalPanel extends StatelessWidget { + const _ApprovalPanel({required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.colorScheme.primary), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: controller.approvals.map((approval) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(approval.title, style: theme.textTheme.titleMedium), + if (approval.detail.isNotEmpty) ...[ + const SizedBox(height: 6), + SelectableText( + approval.detail, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: approval.availableDecisions.map((decision) { + final button = + decision == 'accept' || decision == 'acceptForSession' + ? ElevatedButton( + onPressed: () => + controller.resolveApproval(approval, decision), + child: Text(_decisionLabel(decision)), + ) + : OutlinedButton( + onPressed: () => + controller.resolveApproval(approval, decision), + child: Text(_decisionLabel(decision)), + ); + return button; + }).toList(), + ), + ], + ), + ); + }).toList(), + ), + ); + } + + String _decisionLabel(String decision) { + return switch (decision) { + 'acceptForSession' => 'Accept for session', + _ => decision[0].toUpperCase() + decision.substring(1), + }; + } +} + +class _QueuedPromptBar extends StatelessWidget { + const _QueuedPromptBar({ + required this.controller, + required this.onEditPrompt, + required this.onPromotePrompt, + }); + + final AppController controller; + final ValueChanged onEditPrompt; + final ValueChanged onPromotePrompt; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: controller.pendingPrompts.map((item) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + Icon( + item.mode == PendingPromptMode.steer + ? Icons.settings_outlined + : Icons.schedule_send_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _pendingPromptLabel(item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ), + if (item.mode != PendingPromptMode.steer) + IconButton( + key: ValueKey('pending-prompt-promote-${item.id}'), + tooltip: 'Steer', + onPressed: () => onPromotePrompt(item.id), + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + splashRadius: 16, + icon: const Icon(Icons.settings_outlined, size: 16), + ), + IconButton( + tooltip: 'Edit', + onPressed: () => onEditPrompt(item.id), + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + splashRadius: 16, + icon: const Icon(Icons.edit_outlined, size: 16), + ), + IconButton( + tooltip: 'Cancel', + onPressed: () => controller.cancelPendingPrompt(item.id), + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + splashRadius: 16, + icon: const Icon(Icons.close, size: 16), + ), + ], + ), + ); + }).toList(), + ), + ); + } + + String _pendingPromptLabel(PendingPrompt item) { + final trimmed = item.text.trim(); + final attachmentCount = item.attachments.length; + if (trimmed.isNotEmpty && attachmentCount == 0) { + return trimmed; + } + if (trimmed.isEmpty && attachmentCount > 0) { + return attachmentCount == 1 + ? '1 attachment' + : '$attachmentCount attachments'; + } + if (attachmentCount > 0) { + return '$trimmed • ${attachmentCount == 1 ? '1 attachment' : '$attachmentCount attachments'}'; + } + return 'Pending message'; + } +} + +class _ComposerAttachmentBar extends StatelessWidget { + const _ComposerAttachmentBar({ + required this.attachments, + required this.onRemove, + }); + + final List attachments; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: attachments.map((item) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + item.isImage + ? Icons.image_outlined + : Icons.description_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + item.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(width: 4), + IconButton( + tooltip: 'Remove attachment', + onPressed: () => onRemove(item.id), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 16), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _MonospaceOutputView extends StatelessWidget { + const _MonospaceOutputView({required this.text, required this.style}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final displayText = _repairDisplayText(text); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final lineLengths = displayText.split('\n').map((line) => line.length); + final longestLineLength = lineLengths.isEmpty + ? 0 + : lineLengths.reduce((left, right) => left > right ? left : right); + final fontSize = style?.fontSize ?? 12; + final estimatedCharWidth = fontSize * 0.62; + final contentWidth = (longestLineLength * estimatedCharWidth) + 24; + final targetWidth = + constraints.hasBoundedWidth && contentWidth < constraints.maxWidth + ? constraints.maxWidth + : contentWidth; + final content = SizedBox( + width: targetWidth, + child: SelectionArea( + child: Text( + displayText, + overflow: TextOverflow.visible, + softWrap: false, + textWidthBasis: TextWidthBasis.longestLine, + strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.2), + style: style, + ), + ), + ); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView(child: content), + ); + }, + ); + } + + String _repairDisplayText(String value) { + final lines = value.split('\n'); + if (lines.length < 8) { + return value; + } + + final repaired = []; + final run = []; + + void flushRun() { + if (run.isEmpty) { + return; + } + final nonEmpty = run.where((line) => line.isNotEmpty).toList(); + final mostlySingleChar = + nonEmpty.length >= 6 && + nonEmpty.every((line) { + final trimmed = line.trim(); + return line.runes.length == 1 || trimmed.runes.length == 1; + }); + if (mostlySingleChar) { + final joined = run.join(); + if (repaired.isNotEmpty && joined.startsWith(RegExp(r'\s'))) { + repaired[repaired.length - 1] = '${repaired.last}$joined'; + } else { + repaired.add(joined); + } + } else { + repaired.addAll(run); + } + run.clear(); + } + + for (final line in lines) { + final trimmed = line.trim(); + final isRepairableSingleChar = + line.isEmpty || line.runes.length == 1 || trimmed.runes.length == 1; + if (isRepairableSingleChar) { + run.add(line); + } else { + flushRun(); + repaired.add(line); + } + } + flushRun(); + + return repaired.join('\n'); + } +} + +class _EntryTile extends StatelessWidget { + const _EntryTile({ + required this.entry, + required this.onEditMessage, + required this.onOpenFileReference, + }); + + final ActivityEntry entry; + final ValueChanged onEditMessage; + final Future Function(String path, {int? line}) onOpenFileReference; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isUserMessage = entry.kind == EntryKind.user; + final isAgentMessage = entry.kind == EntryKind.agent; + final isSystemMessage = entry.kind == EntryKind.system; + final isPendingUserMessage = isUserMessage && entry.isLocalPending; + final normalizedMessageText = + (entry.body.isEmpty ? entry.title : entry.body).trim(); + final isContextCompacting = + isSystemMessage && + normalizedMessageText.toLowerCase().contains('context compact'); + final isCard = + entry.kind == EntryKind.command || + entry.kind == EntryKind.fileChange || + entry.kind == EntryKind.tool; + final systemBorderColor = Color.alphaBlend( + const Color(0xFFFFA24C).withValues(alpha: 0.7), + scheme.outlineVariant, + ); + final systemTextColor = Color.alphaBlend( + const Color(0xFFFFC48A).withValues(alpha: 0.9), + scheme.onSurfaceVariant, + ); + final tone = switch (entry.kind) { + EntryKind.user => scheme.primary.withValues(alpha: 0.16), + EntryKind.agent => theme.colorScheme.surface, + EntryKind.reasoning => Colors.transparent, + EntryKind.command => scheme.surface, + EntryKind.fileChange => scheme.surface, + EntryKind.tool => scheme.surface, + EntryKind.system => const Color(0xFFFFA24C).withValues(alpha: 0.04), + }; + final pendingUserBorderColor = scheme.outlineVariant.withValues(alpha: 0.8); + final pendingUserTextColor = scheme.onSurfaceVariant.withValues( + alpha: 0.58, + ); + + final monospace = + entry.kind == EntryKind.command || + entry.kind == EntryKind.fileChange || + entry.title.contains('MCP') || + entry.body.contains('{') || + entry.body.contains('diff'); + final messageText = normalizedMessageText; + final canEditMessage = + entry.kind == EntryKind.user && messageText.trim().isNotEmpty; + final cardTitle = entry.kind == EntryKind.fileChange + ? _summarizeFileChangeTitle(entry.body, fallback: entry.title) + : entry.title; + + if (isContextCompacting) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Divider( + color: theme.dividerColor, + thickness: 1, + height: 1, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Context Compacting', + style: theme.textTheme.bodySmall?.copyWith( + color: systemTextColor, + fontWeight: FontWeight.w300, + letterSpacing: 0.2, + ), + ), + ), + Expanded( + child: Divider( + color: theme.dividerColor, + thickness: 1, + height: 1, + ), + ), + ], + ), + ); + } + + Widget? bodyContent; + if (isCard && entry.body.isNotEmpty) { + if (entry.kind == EntryKind.fileChange) { + bodyContent = _ExpandableEntryBody( + text: entry.body, + previewText: _collapsedPreviewText(entry.body), + child: _GitDiffView(text: entry.body), + ); + } else if (monospace) { + bodyContent = _MonospaceOutputView( + text: entry.body, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + height: 1.2, + ), + ); + } else { + bodyContent = SelectableText( + entry.body, + style: theme.textTheme.bodyMedium?.copyWith(height: 1.45), + ); + } + + if (entry.kind == EntryKind.tool || entry.kind == EntryKind.command) { + bodyContent = _ExpandableEntryBody( + text: entry.body, + previewText: _collapsedPreviewText(entry.body), + child: bodyContent, + ); + } + } + + final bubbleContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isCard) + Row( + children: [ + Expanded( + child: Text(cardTitle, style: theme.textTheme.titleMedium), + ), + if (entry.status.isNotEmpty) + Text(entry.status, style: theme.textTheme.bodySmall), + ], + ) + else + isAgentMessage + ? _AgentMarkdownMessage( + text: messageText, + style: theme.textTheme.bodyLarge?.copyWith( + height: 1.5, + color: theme.colorScheme.onSurface, + ), + onOpenFileReference: onOpenFileReference, + ) + : _MessageContentText( + text: messageText, + style: theme.textTheme.bodyLarge?.copyWith( + height: 1.5, + color: isSystemMessage + ? systemTextColor + : isPendingUserMessage + ? pendingUserTextColor + : theme.colorScheme.onSurface, + fontWeight: isSystemMessage ? FontWeight.w300 : null, + ), + canEdit: canEditMessage, + onEdit: () => onEditMessage(entry), + onOpenFileReference: onOpenFileReference, + ), + if (entry.secondary.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(entry.secondary, style: theme.textTheme.bodySmall), + ], + if (bodyContent != null) ...[ + const SizedBox(height: 10), + bodyContent, + ], + if (entry.isStreaming) ...[ + const SizedBox(height: 10), + const LinearProgressIndicator(minHeight: 2), + ], + ], + ); + + return Container( + width: double.infinity, + alignment: isUserMessage ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: BoxConstraints( + maxWidth: isUserMessage || isAgentMessage + ? MediaQuery.sizeOf(context).width * 0.84 + : double.infinity, + ), + padding: EdgeInsets.all( + isCard || isUserMessage || isAgentMessage || isSystemMessage ? 14 : 0, + ), + decoration: BoxDecoration( + color: isPendingUserMessage ? Colors.transparent : tone, + border: isPendingUserMessage + ? Border.all(color: pendingUserBorderColor, width: 0.9) + : isSystemMessage + ? Border.all(color: systemBorderColor, width: 1) + : isCard || isAgentMessage + ? Border.all(color: theme.dividerColor) + : null, + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: isPendingUserMessage ? Clip.antiAlias : Clip.none, + child: isPendingUserMessage + ? Stack( + children: [ + Positioned.fill( + child: _PendingMessageSheen( + color: scheme.primary.withValues(alpha: 0.12), + ), + ), + bubbleContent, + ], + ) + : bubbleContent, + ), + ); + } +} + +class _PendingMessageSheen extends StatefulWidget { + const _PendingMessageSheen({required this.color}); + + final Color color; + + @override + State<_PendingMessageSheen> createState() => _PendingMessageSheenState(); +} + +class _PendingMessageSheenState extends State<_PendingMessageSheen> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + key: const ValueKey('pending-message-sheen'), + child: AnimatedBuilder( + animation: _controller, + builder: (BuildContext context, Widget? child) { + final slide = Tween( + begin: -1.2, + end: 1.2, + ).transform(Curves.easeInOut.transform(_controller.value)); + return FractionalTranslation( + translation: Offset(slide, 0), + child: child, + ); + }, + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: 0.5, + heightFactor: 1, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + widget.color, + Colors.transparent, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _MessageContentText extends StatelessWidget { + const _MessageContentText({ + required this.text, + required this.style, + required this.canEdit, + required this.onEdit, + required this.onOpenFileReference, + }); + + final String text; + final TextStyle? style; + final bool canEdit; + final VoidCallback onEdit; + final Future Function(String path, {int? line}) onOpenFileReference; + + @override + Widget build(BuildContext context) { + return SelectableText.rich( + _buildSpans(context), + contextMenuBuilder: + (BuildContext context, EditableTextState editableTextState) { + final items = [ + ...editableTextState.contextMenuButtonItems, + if (canEdit) + ContextMenuButtonItem( + label: 'Edit', + onPressed: () { + ContextMenuController.removeAny(); + onEdit(); + }, + ), + ]; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: items, + ); + }, + style: style, + ); + } + + TextSpan _buildSpans(BuildContext context) { + final matches = <_MessageReferenceMatch>[ + ..._matchMarkdownReferences(text), + ..._matchPlainReferences(text), + ]..sort((left, right) => left.start.compareTo(right.start)); + + final filteredMatches = <_MessageReferenceMatch>[]; + var lastEnd = 0; + for (final match in matches) { + if (match.start < lastEnd) { + continue; + } + filteredMatches.add(match); + lastEnd = match.end; + } + + if (filteredMatches.isEmpty) { + return TextSpan(text: text, style: style); + } + + final linkStyle = style?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ); + final spans = []; + var cursor = 0; + for (final match in filteredMatches) { + if (match.start > cursor) { + spans.add(TextSpan(text: text.substring(cursor, match.start))); + } + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + unawaited(onOpenFileReference(match.path, line: match.line)); + }, + child: Text(match.displayText, style: linkStyle), + ), + ), + ); + cursor = match.end; + } + if (cursor < text.length) { + spans.add(TextSpan(text: text.substring(cursor))); + } + return TextSpan(style: style, children: spans); + } +} + +class _AgentMarkdownMessage extends StatelessWidget { + const _AgentMarkdownMessage({ + required this.text, + required this.style, + required this.onOpenFileReference, + }); + + final String text; + final TextStyle? style; + final Future Function(String path, {int? line}) onOpenFileReference; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final resolvedStyle = style ?? theme.textTheme.bodyLarge; + return SelectionArea( + child: MarkdownBody( + data: _linkifyPlainFileReferences(text), + softLineBreak: true, + onTapLink: (String linkText, String? href, String title) { + if (href == null || href.isEmpty) { + return; + } + final resolved = _parseReferenceTarget(href); + if (resolved == null) { + return; + } + unawaited(onOpenFileReference(resolved.path, line: resolved.line)); + }, + styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( + p: resolvedStyle, + h1: theme.textTheme.headlineSmall?.copyWith( + color: resolvedStyle?.color, + fontWeight: FontWeight.w700, + ), + h2: theme.textTheme.titleLarge?.copyWith( + color: resolvedStyle?.color, + fontWeight: FontWeight.w700, + ), + h3: theme.textTheme.titleMedium?.copyWith( + color: resolvedStyle?.color, + fontWeight: FontWeight.w700, + ), + code: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurface, + ), + codeblockDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.dividerColor), + ), + a: resolvedStyle?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + blockquotePadding: const EdgeInsets.only(left: 12), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.colorScheme.primary, width: 2), + ), + ), + ), + builders: { + 'a': _MarkdownFileLinkBuilder( + onOpenFileReference: onOpenFileReference, + style: resolvedStyle?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + }, + ), + ); + } +} + +class _MarkdownFileLinkBuilder extends MarkdownElementBuilder { + _MarkdownFileLinkBuilder({ + required this.onOpenFileReference, + required this.style, + }); + + final Future Function(String path, {int? line}) onOpenFileReference; + final TextStyle? style; + + @override + Widget visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final href = element.attributes['href']; + final resolved = href == null ? null : _parseReferenceTarget(href); + final linkStyle = style ?? preferredStyle ?? parentStyle; + return Text.rich( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: resolved == null + ? null + : () { + unawaited( + onOpenFileReference(resolved.path, line: resolved.line), + ); + }, + child: Text(element.textContent, style: linkStyle), + ), + ), + ); + } +} + +class _MessageReferenceMatch { + const _MessageReferenceMatch({ + required this.start, + required this.end, + required this.displayText, + required this.path, + required this.line, + }); + + final int start; + final int end; + final String displayText; + final String path; + final int? line; +} + +class _ResolvedReference { + const _ResolvedReference({required this.path, required this.line}); + + final String path; + final int? line; +} + +Iterable<_MessageReferenceMatch> _matchMarkdownReferences(String input) sync* { + final pattern = RegExp(r'\[([^\]]+)\]\(([^)\s]+)\)'); + for (final match in pattern.allMatches(input)) { + final target = match.group(2); + if (target == null || target.isEmpty) { + continue; + } + final resolved = _parseReferenceTarget(target); + if (resolved == null) { + continue; + } + yield _MessageReferenceMatch( + start: match.start, + end: match.end, + displayText: match.group(1) ?? target, + path: resolved.path, + line: resolved.line, + ); + } +} + +Iterable<_MessageReferenceMatch> _matchPlainReferences(String input) sync* { + final pattern = RegExp( + r'(?= 0 ? target.substring(0, hashIndex) : target; + final hash = hashIndex >= 0 ? target.substring(hashIndex + 1) : ''; + int? line; + + final colonMatch = RegExp(r'^(.*):(\d+)$').firstMatch(path); + if (colonMatch != null && + !path.startsWith('ws://') && + !path.startsWith('http://') && + !path.startsWith('https://')) { + path = colonMatch.group(1) ?? path; + line = int.tryParse(colonMatch.group(2) ?? ''); + } + + if (hash.startsWith('L')) { + line = int.tryParse(hash.substring(1)); + } + + if (path.isEmpty) { + return null; + } + return _ResolvedReference(path: path, line: line); +} + +String _linkifyPlainFileReferences(String input) { + final markdownMatches = _matchMarkdownReferences(input).toList(); + final plainMatches = _matchPlainReferences(input).where((plainMatch) { + for (final markdownMatch in markdownMatches) { + if (plainMatch.start >= markdownMatch.start && + plainMatch.end <= markdownMatch.end) { + return false; + } + } + return true; + }).toList()..sort((left, right) => left.start.compareTo(right.start)); + + if (plainMatches.isEmpty) { + return input; + } + + final buffer = StringBuffer(); + var cursor = 0; + for (final match in plainMatches) { + if (match.start < cursor) { + continue; + } + buffer.write(input.substring(cursor, match.start)); + final href = match.line == null + ? match.path + : '${match.path}#L${match.line}'; + buffer.write('[${match.displayText}]($href)'); + cursor = match.end; + } + if (cursor < input.length) { + buffer.write(input.substring(cursor)); + } + return buffer.toString(); +} + +class _ExpandableEntryBody extends StatefulWidget { + const _ExpandableEntryBody({ + required this.text, + required this.child, + this.previewText, + }); + + final String text; + final Widget child; + final String? previewText; + + @override + State<_ExpandableEntryBody> createState() => _ExpandableEntryBodyState(); +} + +class _ExpandableEntryBodyState extends State<_ExpandableEntryBody> { + bool _isExpanded = false; + + bool get _shouldCollapse { + final lines = '\n'.allMatches(widget.text).length + 1; + return lines > 12 || widget.text.length > 900; + } + + @override + Widget build(BuildContext context) { + if (!_shouldCollapse) { + return widget.child; + } + + final theme = Theme.of(context); + final previewText = (widget.previewText ?? widget.text).trim(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedCrossFade( + firstChild: Stack( + children: [ + SizedBox( + width: double.infinity, + child: Text( + previewText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + height: 1.25, + color: theme.colorScheme.onSurface, + ), + ), + ), + if (previewText.contains('\n') || previewText.length > 120) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: Container( + height: 18, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.colorScheme.surface.withValues(alpha: 0), + theme.colorScheme.surface, + ], + ), + ), + ), + ), + ), + ], + ), + secondChild: widget.child, + crossFadeState: _isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 140), + ), + const SizedBox(height: 8), + TextButton.icon( + key: ValueKey( + _isExpanded ? 'entry-body-collapse' : 'entry-body-expand', + ), + onPressed: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + icon: Icon(_isExpanded ? Icons.unfold_less : Icons.unfold_more), + label: Text(_isExpanded ? 'Collapse' : 'Expand'), + ), + ], + ); + } +} + +class _GitDiffView extends StatelessWidget { + const _GitDiffView({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final lines = text.split('\n'); + final rows = lines.map((line) => _GitDiffLine.fromRaw(line)).toList(); + final style = theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + height: 1.25, + ); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + key: const ValueKey('git-diff-view'), + width: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.35, + ), + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: rows.map((row) { + return ColoredBox( + color: row.backgroundColor(theme.colorScheme), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + child: Text( + row.displayText, + style: style?.copyWith( + color: row.foregroundColor(theme.colorScheme), + fontWeight: row.isEmphasized + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ), + ); + } +} + +String _collapsedPreviewText(String text) { + final lines = text + .split('\n') + .map((line) => line.trimRight()) + .where((line) => line.isNotEmpty) + .take(2) + .toList(); + if (lines.isEmpty) { + return text.trim(); + } + return lines.join('\n'); +} + +String _summarizeFileChangeTitle(String text, {required String fallback}) { + final lines = text.split('\n'); + String fileName = fallback; + var added = 0; + var removed = 0; + + for (final rawLine in lines) { + final line = rawLine.trim(); + if (line.isEmpty) { + continue; + } + if (fileName == fallback) { + if (line.contains(' • ')) { + fileName = line.split(' • ').first.trim(); + } else if (line.startsWith('+++ ')) { + fileName = line.substring(4).replaceFirst(RegExp(r'^[ab]/'), '').trim(); + } else if (line.startsWith('diff --git ')) { + final parts = line.split(' '); + if (parts.length >= 4) { + fileName = parts[2].replaceFirst(RegExp(r'^[ab]/'), '').trim(); + } + } + } + if (rawLine.startsWith('+') && !rawLine.startsWith('+++ ')) { + added += 1; + } else if (rawLine.startsWith('-') && !rawLine.startsWith('--- ')) { + removed += 1; + } + } + + final segments = [fileName]; + if (added > 0) { + segments.add('+$added'); + } + if (removed > 0) { + segments.add('-$removed'); + } + return segments.join(' '); +} + +class _GitDiffLine { + const _GitDiffLine({required this.displayText, required this.kind}); + + factory _GitDiffLine.fromRaw(String raw) { + if (raw.startsWith('diff --git') || + raw.startsWith('index ') || + raw.startsWith('--- ') || + raw.startsWith('+++ ')) { + return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.header); + } + if (raw.startsWith('@@')) { + return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.hunk); + } + if (raw.startsWith('+')) { + return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.addition); + } + if (raw.startsWith('-')) { + return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.removal); + } + if (raw.contains(' • ')) { + return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.meta); + } + return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.context); + } + + final String displayText; + final _GitDiffLineKind kind; + + bool get isEmphasized { + return switch (kind) { + _GitDiffLineKind.header || + _GitDiffLineKind.hunk || + _GitDiffLineKind.meta => true, + _GitDiffLineKind.addition || + _GitDiffLineKind.removal || + _GitDiffLineKind.context => false, + }; + } + + Color backgroundColor(ColorScheme scheme) { + return switch (kind) { + _GitDiffLineKind.addition => Colors.green.withValues(alpha: 0.14), + _GitDiffLineKind.removal => Colors.red.withValues(alpha: 0.14), + _GitDiffLineKind.hunk => scheme.tertiary.withValues(alpha: 0.12), + _GitDiffLineKind.meta => scheme.primary.withValues(alpha: 0.08), + _GitDiffLineKind.header => scheme.surfaceContainerHighest.withValues( + alpha: 0.6, + ), + _GitDiffLineKind.context => Colors.transparent, + }; + } + + Color foregroundColor(ColorScheme scheme) { + return switch (kind) { + _GitDiffLineKind.addition => Colors.green.shade800, + _GitDiffLineKind.removal => Colors.red.shade800, + _GitDiffLineKind.hunk => scheme.tertiary, + _GitDiffLineKind.meta => scheme.primary, + _GitDiffLineKind.header => scheme.onSurfaceVariant, + _GitDiffLineKind.context => scheme.onSurface, + }; + } +} + +enum _GitDiffLineKind { meta, header, hunk, addition, removal, context } diff --git a/lib/src/home_page.dart b/lib/src/home_page.dart index 8eea8a0..a032f09 100644 --- a/lib/src/home_page.dart +++ b/lib/src/home_page.dart @@ -1,6634 +1,8 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:flutter/services.dart'; -import 'package:image/image.dart' as img; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:open_filex/open_filex.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:super_clipboard/super_clipboard.dart'; - -import 'app_controller.dart'; -import 'models.dart'; - -class HomePage extends StatefulWidget { - const HomePage({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => _HomePageState(); -} - -Future openDownloadedLocation( - BuildContext context, - String savedPath, -) async { - if (Platform.isAndroid) { - final currentStatus = await Permission.manageExternalStorage.status; - if (!currentStatus.isGranted) { - final requested = await Permission.manageExternalStorage.request(); - if (!requested.isGranted) { - await openAppSettings(); - if (!context.mounted) { - return; - } - final messenger = ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - 'Allow All files access for Codex Remote to open downloaded locations.', - ), - ), - ); - return; - } - } - } - final parentPath = File(savedPath).parent.path; - var result = await OpenFilex.open(parentPath); - if (result.type != ResultType.done) { - result = await OpenFilex.open(savedPath); - } - if (result.type == ResultType.done || !context.mounted) { - return; - } - final messenger = ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - result.message.isNotEmpty - ? result.message - : 'Unable to open the downloaded file location.', - ), - ), - ); -} - -class _HomePageState extends State with TickerProviderStateMixin { - static const int _maxImageAttachmentBytes = 2 * 1024 * 1024; - static const int _maxImageAttachmentDimension = 1600; - final TextEditingController _composerController = TextEditingController(); - final List _composerAttachments = []; - final FocusNode _composerFocusNode = FocusNode(); - late final AnimationController _downloadPulseController; - late final AnimationController _downloadPopController; - int _previousActiveDownloadCount = 0; - int _previousDownloadCount = 0; - bool _showActionBar = true; - - @override - void initState() { - super.initState(); - ClipboardEvents.instance?.registerPasteEventListener(_onPasteEvent); - _downloadPulseController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - ); - _downloadPopController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 240), - ); - widget.controller.addListener(_handleControllerChanged); - _previousActiveDownloadCount = widget.controller.activeDownloadCount; - _previousDownloadCount = widget.controller.downloadRecords.length; - _syncDownloadAnimations(); - } - - @override - void dispose() { - widget.controller.removeListener(_handleControllerChanged); - ClipboardEvents.instance?.unregisterPasteEventListener(_onPasteEvent); - _composerController.dispose(); - _composerFocusNode.dispose(); - _downloadPulseController.dispose(); - _downloadPopController.dispose(); - super.dispose(); - } - - Future _showRateLimitMenu( - BuildContext context, - AppController controller, - ) async { - if (!controller.hasRateLimitResetDetails) { - return; - } - final box = context.findRenderObject(); - final overlay = Overlay.of(context).context.findRenderObject(); - if (box is! RenderBox || overlay is! RenderBox) { - return; - } - final topLeft = box.localToGlobal(Offset.zero, ancestor: overlay); - final bottomRight = box.localToGlobal( - box.size.bottomRight(Offset.zero), - ancestor: overlay, - ); - final position = RelativeRect.fromRect( - Rect.fromPoints(topLeft, bottomRight), - Offset.zero & overlay.size, - ); - await showMenu( - context: context, - position: position, - items: controller.rateLimitResetDetails - .map( - (detail) => PopupMenuItem( - enabled: false, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Text(detail), - ), - ) - .toList(), - ); - } - - @override - Widget build(BuildContext context) { - final controller = widget.controller; - final theme = Theme.of(context); - - return AnimatedBuilder( - animation: controller, - builder: (BuildContext context, Widget? child) { - final isConnecting = - controller.status == ConnectionStatus.connecting || - controller.status == ConnectionStatus.initializing; - return Scaffold( - appBar: AppBar( - titleSpacing: 12, - title: _TopBarTitle( - controller: controller, - onRenameActiveThread: () => _promptRenameActiveThread(context), - ), - actions: [ - IconButton( - tooltip: _showActionBar ? 'Hide actions' : 'Show actions', - onPressed: () { - setState(() { - _showActionBar = !_showActionBar; - }); - }, - icon: Icon( - _showActionBar - ? Icons.arrow_drop_up_rounded - : Icons.arrow_drop_down_rounded, - size: 30, - ), - ), - IconButton( - tooltip: 'Settings', - onPressed: () => _openSettings(context), - icon: const Icon(Icons.settings_outlined), - ), - ], - ), - body: Stack( - children: [ - SafeArea( - top: false, - child: Column( - children: [ - AnimatedCrossFade( - duration: const Duration(milliseconds: 180), - crossFadeState: _showActionBar - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: _ActionBar( - controller: controller, - pulse: _downloadPulseController, - pop: _downloadPopController, - onOpenThreads: () => _openThreadHistory(context), - onOpenFiles: () => _openFiles(context), - onOpenCommands: () => _openCommandCenter(context), - onOpenAutomations: () => _openAutomations(context), - onToggleConnection: () async { - if (controller.isConnected) { - await controller.disconnect(); - } else { - await controller.connect(); - } - }, - onOpenDownloads: () => _openDownloadCenter(context), - ), - secondChild: Container( - width: double.infinity, - height: 0, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.dividerColor), - ), - ), - ), - ), - if (controller.approvals.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _ApprovalPanel(controller: controller), - ), - Expanded( - child: controller.entries.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Connect to a Codex app-server, then send a prompt. Command output, file changes, approvals, and automations will appear in the same timeline.', - style: theme.textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - ], - ), - ), - ) - : ListView.separated( - reverse: true, - padding: const EdgeInsets.fromLTRB(16, 18, 16, 24), - itemBuilder: (BuildContext context, int index) { - final entry = controller.entries[ - controller.entries.length - 1 - index]; - return _EntryTile( - entry: entry, - onEditMessage: _editTimelineMessage, - onOpenFileReference: _openFileReference, - ); - }, - separatorBuilder: (_, _) => - const SizedBox(height: 12), - itemCount: controller.entries.length, - ), - ), - Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor), - ), - ), - padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), - child: Column( - children: [ - if (controller.queuedPromptCount > 0) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _QueuedPromptBar( - controller: controller, - onEditPrompt: _editPendingPrompt, - onPromotePrompt: widget - .controller - .promotePendingPromptToSteer, - ), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _FooterIconButton( - tooltip: 'Attach', - icon: Icons.attach_file_outlined, - onPressed: _pickComposerAttachments, - ), - const SizedBox(width: 8), - _FooterActionButton( - label: controller.settings.planMode - ? 'Plan on' - : 'Plan off', - icon: Icons.route_outlined, - onPressed: () => _togglePlanMode(controller), - ), - const SizedBox(width: 8), - _FooterActionButton( - label: _modelLabel(controller), - icon: Icons.tune_outlined, - onPressed: () => - _editModel(context, controller), - ), - const SizedBox(width: 8), - _FooterActionButton( - label: controller.settings.reasoningEffort, - icon: Icons.psychology_alt_outlined, - onPressed: () => _pickReasoningEffort( - context, - controller, - ), - ), - if (controller.hasActiveTurn) ...[ - const SizedBox(width: 8), - _FooterIconButton( - tooltip: 'Stop', - icon: Icons.stop_circle_outlined, - onPressed: controller.interruptTurn, - ), - ], - ], - ), - ), - const SizedBox(height: 8), - if (_composerAttachments.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _ComposerAttachmentBar( - attachments: _composerAttachments, - onRemove: _removeComposerAttachment, - ), - ), - TextField( - controller: _composerController, - focusNode: _composerFocusNode, - minLines: 1, - maxLines: 6, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - hintText: 'Message Codex...', - ), - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.hasActiveTurn) ...[ - OutlinedButton( - onPressed: controller.isSteering - ? null - : () => _steerPrompt(controller), - child: Text( - controller.isSteering ? 'Steering...' : 'Steer', - ), - ), - const SizedBox(width: 10), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ElevatedButton( - onPressed: () => _sendPrompt(controller), - child: Text( - controller.hasActiveTurn ? 'Queue' : 'Send', - ), - ), - if (controller.composerMetaLeftText != null || - controller.composerMetaRightText != - null) ...[ - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: Builder( - builder: (BuildContext context) { - final text = Text( - controller.composerMetaLeftText ?? - '', - key: const ValueKey( - 'composer-meta-left-text', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: theme.textTheme.bodySmall - ?.copyWith( - fontSize: 10, - height: 1.1, - color: theme - .colorScheme - .onSurfaceVariant, - ), - ); - if (!controller - .hasRateLimitResetDetails) { - return text; - } - return InkWell( - key: const ValueKey( - 'composer-meta-left-button', - ), - borderRadius: - BorderRadius.circular(6), - onTap: () => _showRateLimitMenu( - context, - controller, - ), - child: Padding( - padding: - const EdgeInsets.symmetric( - vertical: 2, - ), - child: text, - ), - ); - }, - ), - ), - if (controller.composerMetaRightText != - null && - controller - .composerMetaRightText! - .isNotEmpty) ...[ - const SizedBox(width: 8), - if (controller.contextUsagePercent != - null) - Row( - key: const ValueKey( - 'composer-meta-right-indicator', - ), - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - value: - controller - .contextUsagePercent! / - 100, - strokeWidth: 2, - backgroundColor: theme - .colorScheme - .surfaceContainerHighest, - valueColor: - AlwaysStoppedAnimation< - Color - >( - theme - .colorScheme - .primary, - ), - ), - ), - const SizedBox(width: 4), - Text( - '${controller.contextUsagePercent!.toString().padLeft(2, '0')}%', - key: const ValueKey( - 'composer-meta-right-percent', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: theme.textTheme.bodySmall - ?.copyWith( - fontSize: 10, - height: 1.1, - color: theme - .colorScheme - .onSurfaceVariant, - ), - ), - ], - ) - else - Text( - controller.composerMetaRightText!, - key: const ValueKey( - 'composer-meta-right-text', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: theme.textTheme.bodySmall - ?.copyWith( - fontSize: 10, - height: 1.1, - color: theme - .colorScheme - .onSurfaceVariant, - ), - ), - ], - ], - ), - ], - ], - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - if (isConnecting) - Positioned.fill( - child: AbsorbPointer( - child: Container( - key: const ValueKey('connection-overlay'), - color: theme.colorScheme.scrim.withValues(alpha: 0.24), - child: const Center( - child: CircularProgressIndicator( - key: ValueKey('connection-overlay-spinner'), - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - Future _sendPrompt(AppController controller) async { - if (!(await _ensureThreadDirectorySelected(controller))) { - return; - } - final prompt = _composerController.text; - final attachments = List.from(_composerAttachments); - _composerController.clear(); - setState(() { - _composerAttachments.clear(); - }); - await controller.sendPrompt(prompt, attachments: attachments); - } - - Future _ensureThreadDirectorySelected(AppController controller) async { - if (!controller.needsThreadDirectorySelection) { - return true; - } - final directory = await _pickThreadDirectory(controller); - if (directory == null || directory.isEmpty) { - return false; - } - await controller.startFreshThreadInDirectory(directory); - return true; - } - - Future _createThreadWithDirectory(AppController controller) async { - final directory = await _pickThreadDirectory(controller); - if (directory == null || directory.isEmpty) { - return; - } - await controller.startFreshThreadInDirectory(directory); - } - - Future _pickThreadDirectory(AppController controller) async { - return Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return AutomationPathPickerPage( - controller: controller, - allowDirectorySelection: true, - allowFileSelection: false, - title: 'Select thread folder', - initialPath: controller.preferredFileBrowserRoot, - ); - }, - ), - ); - } - - Future _steerPrompt(AppController controller) async { - final prompt = _composerController.text; - if (prompt.trim().isEmpty && _composerAttachments.isEmpty) { - return; - } - final attachments = List.from(_composerAttachments); - final accepted = await controller.steerPrompt( - prompt, - attachments: attachments, - ); - if (accepted) { - _composerController.clear(); - setState(() { - _composerAttachments.clear(); - }); - } - } - - void _editTimelineMessage(ActivityEntry entry) { - final content = (entry.body.isEmpty ? entry.title : entry.body).trim(); - if (content.isEmpty) { - return; - } - _composerController - ..text = content - ..selection = TextSelection.collapsed(offset: content.length); - } - - void _dismissComposerFocus() { - _composerFocusNode.unfocus(); - FocusManager.instance.primaryFocus?.unfocus(); - } - - Future _openFileReference(String path, {int? line}) async { - final resolvedPath = widget.controller.resolveFileReferencePath(path); - if (resolvedPath == null || resolvedPath.isEmpty) { - return; - } - _dismissComposerFocus(); - await widget.controller.openFile(resolvedPath, highlightedLine: line); - if (!mounted) { - return; - } - await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return FilePreviewPage( - controller: widget.controller, - onDownload: _downloadPreviewFile, - onCancelDownload: _cancelPreviewDownload, - ); - }, - ), - ); - } - - Future _downloadPreviewFile( - BuildContext context, - String filePath, - ) async { - try { - await widget.controller.saveFileToDevice(filePath); - } catch (error) { - // Download errors are surfaced in the download center. - } - } - - Future _cancelPreviewDownload(String filePath) async { - await widget.controller.cancelFileDownload(filePath); - } - - Future _promptRenameActiveThread(BuildContext context) async { - final threadId = widget.controller.activeThreadId?.trim() ?? ''; - if (threadId.isEmpty) { - return; - } - _dismissComposerFocus(); - final textController = TextEditingController( - text: widget.controller.activeThreadName?.trim() ?? '', - ); - final nextName = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Rename thread'), - content: TextField( - controller: textController, - autofocus: true, - decoration: const InputDecoration(labelText: 'Thread name'), - onSubmitted: (value) => Navigator.of(context).pop(value.trim()), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => - Navigator.of(context).pop(textController.text.trim()), - child: const Text('Save'), - ), - ], - ); - }, - ); - if (nextName == null) { - return; - } - await widget.controller.renameThread(threadId, nextName); - } - - Future _openDownloadCenter(BuildContext context) async { - _dismissComposerFocus(); - await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return DownloadCenterPage(controller: widget.controller); - }, - ), - ); - } - - void _handleControllerChanged() { - final activeCount = widget.controller.activeDownloadCount; - final totalCount = widget.controller.downloadRecords.length; - if (activeCount != _previousActiveDownloadCount || - totalCount != _previousDownloadCount) { - _downloadPopController.forward(from: 0); - _previousActiveDownloadCount = activeCount; - _previousDownloadCount = totalCount; - _syncDownloadAnimations(); - } - } - - void _syncDownloadAnimations() { - if (widget.controller.activeDownloadCount > 0) { - if (!_downloadPulseController.isAnimating) { - _downloadPulseController.repeat(reverse: true); - } - } else { - _downloadPulseController.stop(); - _downloadPulseController.value = 0; - } - } - - void _editPendingPrompt(String pendingId) { - final value = widget.controller.takePendingPromptForEditing(pendingId); - if (value == null) { - return; - } - _composerController.text = value.text; - _composerController.selection = TextSelection.collapsed( - offset: value.text.length, - ); - setState(() { - _composerAttachments - ..clear() - ..addAll(value.attachments); - }); - } - - Future _pickComposerAttachments() async { - _dismissComposerFocus(); - final result = await FilePicker.platform.pickFiles( - allowMultiple: true, - withData: true, - ); - if (result == null || !mounted) { - return; - } - final nextAttachments = []; - final rejected = []; - for (final file in result.files) { - final bytes = - file.bytes ?? - (file.path == null ? null : await File(file.path!).readAsBytes()); - final name = file.name.trim(); - if (bytes == null || name.isEmpty) { - continue; - } - final attachment = await _attachmentFromBytes( - fileName: name, - bytes: bytes, - mimeType: file.extension == null ? null : _mimeTypeForFileName(name), - ); - if (attachment == null) { - rejected.add(name); - } else { - nextAttachments.add(attachment); - } - } - if (nextAttachments.isNotEmpty) { - setState(() { - _composerAttachments.addAll(nextAttachments); - }); - } - if (rejected.isNotEmpty && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Unsupported attachments: ${rejected.join(', ')}'), - ), - ); - } - } - - Future _onPasteEvent(ClipboardReadEvent event) async { - final reader = await event.getClipboardReader(); - await _handleClipboardReader(reader); - } - - Future _handleClipboardReader(ClipboardReader reader) async { - final attachment = await _readImageAttachmentFromClipboard(reader); - if (!mounted || attachment == null) { - return; - } - setState(() { - _composerAttachments.add(attachment); - }); - } - - Future _readImageAttachmentFromClipboard( - ClipboardReader reader, - ) async { - for (final item in reader.items) { - final png = await _readClipboardFile(item, Formats.png); - if (png != null) { - return _imageAttachment( - fileName: await item.getSuggestedName() ?? 'Pasted Image.png', - bytes: png, - mimeType: 'image/png', - ); - } - final jpeg = await _readClipboardFile(item, Formats.jpeg); - if (jpeg != null) { - return _imageAttachment( - fileName: await item.getSuggestedName() ?? 'Pasted Image.jpg', - bytes: jpeg, - mimeType: 'image/jpeg', - ); - } - final gif = await _readClipboardFile(item, Formats.gif); - if (gif != null) { - return _imageAttachment( - fileName: await item.getSuggestedName() ?? 'Pasted Image.gif', - bytes: gif, - mimeType: 'image/gif', - ); - } - final webp = await _readClipboardFile(item, Formats.webp); - if (webp != null) { - return _imageAttachment( - fileName: await item.getSuggestedName() ?? 'Pasted Image.webp', - bytes: webp, - mimeType: 'image/webp', - ); - } - } - return null; - } - - Future _readClipboardFile( - DataReader reader, - FileFormat format, - ) async { - final completer = Completer(); - final progress = reader.getFile( - format, - (DataReaderFile file) async { - try { - completer.complete(await file.readAll()); - } catch (error) { - completer.completeError(error); - } - }, - onError: (Object error) { - completer.completeError(error); - }, - ); - if (progress == null) { - return null; - } - return completer.future; - } - - Future _attachmentFromBytes({ - required String fileName, - required Uint8List bytes, - String? mimeType, - }) async { - if (_isImageFile(fileName, mimeType)) { - return _imageAttachment( - fileName: fileName, - bytes: bytes, - mimeType: mimeType ?? _mimeTypeForFileName(fileName) ?? 'image/png', - ); - } - if (!isLikelyHumanReadableFile(fileName, bytes)) { - return null; - } - return ComposerAttachment( - id: 'attachment-${DateTime.now().microsecondsSinceEpoch}-$fileName', - fileName: fileName, - kind: ComposerAttachmentKind.textFile, - bytes: bytes, - mimeType: mimeType, - textContent: String.fromCharCodes(bytes), - ); - } - - Future _imageAttachment({ - required String fileName, - required Uint8List bytes, - required String mimeType, - }) async { - final prepared = _prepareImageAttachment( - fileName: fileName, - bytes: bytes, - mimeType: mimeType, - ); - return ComposerAttachment( - id: 'attachment-${DateTime.now().microsecondsSinceEpoch}-${prepared.fileName}', - fileName: prepared.fileName, - kind: ComposerAttachmentKind.image, - bytes: prepared.bytes, - mimeType: prepared.mimeType, - ); - } - - _PreparedImageAttachment _prepareImageAttachment({ - required String fileName, - required Uint8List bytes, - required String mimeType, - }) { - final decoded = img.decodeImage(bytes); - if (decoded == null) { - return _PreparedImageAttachment( - fileName: fileName, - bytes: bytes, - mimeType: mimeType, - ); - } - final longestSide = decoded.width > decoded.height - ? decoded.width - : decoded.height; - final shouldResize = longestSide > _maxImageAttachmentDimension; - final shouldReencode = - shouldResize || - bytes.length > _maxImageAttachmentBytes || - mimeType == 'image/heic' || - mimeType == 'image/heif' || - mimeType == 'image/bmp' || - mimeType == 'image/gif' || - mimeType == 'image/webp'; - if (!shouldReencode) { - return _PreparedImageAttachment( - fileName: fileName, - bytes: bytes, - mimeType: mimeType, - ); - } - - img.Image output = decoded; - if (shouldResize) { - if (decoded.width >= decoded.height) { - output = img.copyResize(decoded, width: _maxImageAttachmentDimension); - } else { - output = img.copyResize(decoded, height: _maxImageAttachmentDimension); - } - } - - var quality = 88; - var encoded = Uint8List.fromList(img.encodeJpg(output, quality: quality)); - while (encoded.length > _maxImageAttachmentBytes && quality > 52) { - quality -= 12; - encoded = Uint8List.fromList(img.encodeJpg(output, quality: quality)); - } - return _PreparedImageAttachment( - fileName: _replaceFileExtension(fileName, 'jpg'), - bytes: encoded, - mimeType: 'image/jpeg', - ); - } - - String _replaceFileExtension(String fileName, String extension) { - final dotIndex = fileName.lastIndexOf('.'); - final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); - return '$baseName.$extension'; - } - - bool _isImageFile(String fileName, String? mimeType) { - final type = (mimeType ?? '').toLowerCase(); - if (type.startsWith('image/')) { - return true; - } - final extension = fileName.contains('.') - ? fileName.split('.').last.toLowerCase() - : ''; - return { - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'bmp', - 'heic', - 'heif', - }.contains(extension); - } - - String? _mimeTypeForFileName(String fileName) { - final extension = fileName.contains('.') - ? fileName.split('.').last.toLowerCase() - : ''; - return switch (extension) { - 'png' => 'image/png', - 'jpg' || 'jpeg' => 'image/jpeg', - 'gif' => 'image/gif', - 'webp' => 'image/webp', - 'bmp' => 'image/bmp', - 'heic' => 'image/heic', - 'heif' => 'image/heif', - _ => null, - }; - } - - void _removeComposerAttachment(String id) { - setState(() { - _composerAttachments.removeWhere((item) => item.id == id); - }); - } - - Future _openSettings(BuildContext context) async { - _dismissComposerFocus(); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return SettingsPage(controller: widget.controller); - }, - ), - ); - } - - Future _openAutomations(BuildContext context) async { - _dismissComposerFocus(); - await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return AutomationPage(controller: widget.controller); - }, - ), - ); - } - - Future _openThreadHistory(BuildContext context) async { - _dismissComposerFocus(); - await widget.controller.loadThreadHistory(reset: true); - if (!context.mounted) { - return; - } - await showGeneralDialog( - context: context, - barrierLabel: 'Threads', - barrierDismissible: true, - barrierColor: Colors.black54, - pageBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) { - return Align( - alignment: Alignment.centerLeft, - child: Material( - color: Colors.transparent, - child: SizedBox( - width: MediaQuery.sizeOf(context).width * 0.88, - child: ThreadHistorySheet( - controller: widget.controller, - onCreateThread: () => - _createThreadWithDirectory(widget.controller), - ), - ), - ), - ); - }, - transitionBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ); - return SlideTransition( - position: Tween( - begin: const Offset(-1, 0), - end: Offset.zero, - ).animate(curved), - child: child, - ); - }, - ); - } - - Future _openFiles(BuildContext context) async { - _dismissComposerFocus(); - await widget.controller.openFileBrowser(); - if (!context.mounted) { - return; - } - await showGeneralDialog( - context: context, - barrierLabel: 'Files', - barrierDismissible: true, - barrierColor: Colors.black54, - pageBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) { - return Align( - alignment: Alignment.centerLeft, - child: Material( - color: Colors.transparent, - child: SizedBox( - width: MediaQuery.sizeOf(context).width * 0.92, - child: FileBrowserSheet(controller: widget.controller), - ), - ), - ); - }, - transitionBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ); - return SlideTransition( - position: Tween( - begin: const Offset(-1, 0), - end: Offset.zero, - ).animate(curved), - child: child, - ); - }, - ); - } - - Future _openCommandCenter(BuildContext context) async { - _dismissComposerFocus(); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return CommandCenterPage(controller: widget.controller); - }, - ), - ); - } - - Future _pickReasoningEffort( - BuildContext context, - AppController controller, - ) async { - _dismissComposerFocus(); - final selected = await showModalBottomSheet( - context: context, - builder: (BuildContext context) { - return SafeArea( - top: false, - child: Wrap( - children: ['low', 'medium', 'high', 'xhigh'] - .map( - (item) => ListTile( - title: Text(item), - onTap: () => Navigator.of(context).pop(item), - ), - ) - .toList(), - ), - ); - }, - ); - if (selected == null) { - return; - } - await controller.saveSettings( - controller.settings.copyWith(reasoningEffort: selected), - ); - } - - Future _editModel( - BuildContext context, - AppController controller, - ) async { - _dismissComposerFocus(); - await controller.loadModelOptions(force: true); - if (!context.mounted) { - return; - } - final selected = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (BuildContext context) { - return SafeArea( - top: false, - child: AnimatedBuilder( - animation: controller, - builder: (BuildContext context, Widget? child) { - final theme = Theme.of(context); - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Model', style: theme.textTheme.titleLarge), - const SizedBox(height: 12), - if (controller.modelListError != null) - Text( - controller.modelListError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - if (controller.isLoadingModels) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ) - else - Flexible( - child: ListView( - shrinkWrap: true, - children: [ - ListTile( - title: const Text('Default model'), - subtitle: const Text( - 'Use the server default selection', - ), - selected: controller.settings.model - .trim() - .isEmpty, - onTap: () => Navigator.of(context).pop(''), - ), - ...controller.modelOptions.map((option) { - final value = option.model.trim(); - return ListTile( - title: Text( - option.displayName.isEmpty - ? value - : option.displayName, - ), - subtitle: option.description.isEmpty - ? null - : Text(option.description), - selected: - value.isNotEmpty && - controller.settings.model.trim() == value, - trailing: option.isDefault - ? const Text('Default') - : null, - onTap: () => Navigator.of(context).pop(value), - ); - }), - ], - ), - ), - ], - ), - ); - }, - ), - ); - }, - ); - if (selected == null) { - return; - } - await controller.saveSettings( - controller.settings.copyWith(model: selected), - ); - } - - Future _togglePlanMode(AppController controller) async { - await controller.saveSettings( - controller.settings.copyWith(planMode: !controller.settings.planMode), - ); - } - - String _modelLabel(AppController controller) { - final model = controller.settings.model.trim(); - if (model.isNotEmpty) { - return model; - } - final defaultOption = controller.modelOptions - .cast() - .firstWhere((option) => option?.isDefault == true, orElse: () => null); - if (defaultOption == null) { - return 'Server default'; - } - final displayName = defaultOption.displayName.trim(); - if (displayName.isNotEmpty) { - return displayName; - } - final defaultModel = defaultOption.model.trim(); - return defaultModel.isEmpty ? 'Server default' : defaultModel; - } -} - -class _PreparedImageAttachment { - const _PreparedImageAttachment({ - required this.fileName, - required this.bytes, - required this.mimeType, - }); - - final String fileName; - final Uint8List bytes; - final String mimeType; -} - -class _TopBarTitle extends StatelessWidget { - const _TopBarTitle({ - required this.controller, - required this.onRenameActiveThread, - }); - - final AppController controller; - final VoidCallback onRenameActiveThread; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return InkWell( - borderRadius: BorderRadius.circular(8), - onTap: controller.activeThreadId != null ? onRenameActiveThread : null, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _titleText(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 2), - Text( - _subtitleText(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - ); - } - - String _titleText() { - final activeName = controller.activeThreadName?.trim() ?? ''; - if (activeName.isNotEmpty) { - return activeName; - } - return 'Codex Remote'; - } - - String _subtitleText() { - if (controller.activeThreadCwd.trim().isNotEmpty) { - return controller.activeThreadCwd.trim(); - } - if (controller.settings.connectionMode == ConnectionMode.relay) { - final bridgeLabel = controller.settings.relayBridgeLabel.trim(); - if (bridgeLabel.isNotEmpty) { - return bridgeLabel; - } - if (controller.settings.relayUrl.trim().isNotEmpty) { - return controller.settings.relayUrl.trim(); - } - } - return controller.settings.serverUrl; - } -} - -class _ActionBar extends StatelessWidget { - const _ActionBar({ - required this.controller, - required this.pulse, - required this.pop, - required this.onOpenThreads, - required this.onOpenFiles, - required this.onOpenCommands, - required this.onOpenAutomations, - required this.onToggleConnection, - required this.onOpenDownloads, - }); - - final AppController controller; - final Animation pulse; - final Animation pop; - final VoidCallback onOpenThreads; - final VoidCallback onOpenFiles; - final VoidCallback onOpenCommands; - final VoidCallback onOpenAutomations; - final VoidCallback onToggleConnection; - final VoidCallback onOpenDownloads; - - Widget _animatedActionIcon({required Widget icon, required bool animate}) { - if (!animate) { - return icon; - } - return AnimatedBuilder( - animation: pulse, - builder: (BuildContext context, Widget? child) { - final scale = 1 + (pulse.value * 0.05); - return Transform.scale(scale: scale, child: child); - }, - child: icon, - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final activeCount = controller.activeDownloadCount; - final totalDownloads = controller.downloadRecords.length; - final hasRunningAutomation = controller.automations.any( - (item) => controller.isAutomationRunning(item.id), - ); - final buttonStyle = IconButton.styleFrom( - visualDensity: const VisualDensity(horizontal: -0.5, vertical: -0.5), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - minimumSize: const Size(56, 50), - tapTargetSize: MaterialTapTargetSize.padded, - alignment: Alignment.centerLeft, - ); - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - top: BorderSide(color: theme.dividerColor), - bottom: BorderSide(color: theme.dividerColor), - ), - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - tooltip: 'Threads', - style: buttonStyle, - onPressed: controller.isLoadingHistory ? null : onOpenThreads, - iconSize: 26, - icon: controller.isLoadingHistory - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2.2), - ) - : const Icon(Icons.menu, size: 26), - ), - IconButton( - tooltip: controller.isConnected ? 'Disconnect' : 'Connect', - style: buttonStyle, - onPressed: onToggleConnection, - iconSize: 26, - icon: Icon( - controller.isConnected ? Icons.link_off : Icons.link, - size: 26, - ), - ), - IconButton( - tooltip: 'Command', - style: buttonStyle, - onPressed: onOpenCommands, - iconSize: 26, - icon: const Icon(Icons.terminal, size: 26), - ), - IconButton( - tooltip: 'Files', - style: buttonStyle, - onPressed: onOpenFiles, - iconSize: 26, - icon: const Icon(Icons.folder_outlined, size: 26), - ), - Stack( - clipBehavior: Clip.none, - children: [ - IconButton( - tooltip: 'Automations', - style: buttonStyle, - onPressed: onOpenAutomations, - iconSize: 26, - icon: _animatedActionIcon( - animate: hasRunningAutomation, - icon: const Icon(Icons.account_tree_outlined, size: 26), - ), - ), - if (hasRunningAutomation) - Positioned( - top: 6, - right: 8, - child: SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - key: const ValueKey( - 'automation-running-indicator', - ), - strokeWidth: 1.8, - valueColor: AlwaysStoppedAnimation( - theme.colorScheme.primary, - ), - ), - ), - ), - ], - ), - Stack( - clipBehavior: Clip.none, - children: [ - IconButton( - tooltip: 'Downloads', - style: buttonStyle, - onPressed: onOpenDownloads, - iconSize: 26, - icon: _animatedActionIcon( - animate: activeCount > 0, - icon: Icon( - activeCount > 0 - ? Icons.downloading_rounded - : Icons.download_outlined, - size: 26, - ), - ), - ), - if (totalDownloads > 0) - Positioned( - top: 3, - right: 3, - child: SizedBox( - width: 22, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 1.5, - ), - decoration: BoxDecoration( - color: activeCount > 0 - ? theme.colorScheme.primary - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - activeCount > 0 - ? '$activeCount' - : '$totalDownloads', - style: theme.textTheme.labelSmall?.copyWith( - color: activeCount > 0 - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ); - } -} - -class _FooterActionButton extends StatelessWidget { - const _FooterActionButton({ - required this.label, - required this.icon, - required this.onPressed, - }); - - final String label; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return OutlinedButton( - onPressed: onPressed, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: theme.colorScheme.onSurface), - const SizedBox(width: 8), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - ], - ), - ); - } -} - -class _FooterIconButton extends StatelessWidget { - const _FooterIconButton({ - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Tooltip( - message: tooltip, - child: OutlinedButton( - onPressed: onPressed, - style: OutlinedButton.styleFrom( - minimumSize: const Size(44, 40), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - child: Icon(icon, size: 18, color: theme.colorScheme.onSurface), - ), - ); - } -} - -class _ApprovalPanel extends StatelessWidget { - const _ApprovalPanel({required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.colorScheme.primary), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: controller.approvals.map((approval) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(approval.title, style: theme.textTheme.titleMedium), - if (approval.detail.isNotEmpty) ...[ - const SizedBox(height: 6), - SelectableText( - approval.detail, - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - ), - ), - ], - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: approval.availableDecisions.map((decision) { - final button = - decision == 'accept' || decision == 'acceptForSession' - ? ElevatedButton( - onPressed: () => - controller.resolveApproval(approval, decision), - child: Text(_decisionLabel(decision)), - ) - : OutlinedButton( - onPressed: () => - controller.resolveApproval(approval, decision), - child: Text(_decisionLabel(decision)), - ); - return button; - }).toList(), - ), - ], - ), - ); - }).toList(), - ), - ); - } - - String _decisionLabel(String decision) { - return switch (decision) { - 'acceptForSession' => 'Accept for session', - _ => decision[0].toUpperCase() + decision.substring(1), - }; - } -} - -class _QueuedPromptBar extends StatelessWidget { - const _QueuedPromptBar({ - required this.controller, - required this.onEditPrompt, - required this.onPromotePrompt, - }); - - final AppController controller; - final ValueChanged onEditPrompt; - final ValueChanged onPromotePrompt; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: controller.pendingPrompts.map((item) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Row( - children: [ - Icon( - item.mode == PendingPromptMode.steer - ? Icons.settings_outlined - : Icons.schedule_send_outlined, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _pendingPromptLabel(item), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ), - if (item.mode != PendingPromptMode.steer) - IconButton( - key: ValueKey('pending-prompt-promote-${item.id}'), - tooltip: 'Steer', - onPressed: () => onPromotePrompt(item.id), - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 28, - height: 28, - ), - splashRadius: 16, - icon: const Icon(Icons.settings_outlined, size: 16), - ), - IconButton( - tooltip: 'Edit', - onPressed: () => onEditPrompt(item.id), - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 28, - height: 28, - ), - splashRadius: 16, - icon: const Icon(Icons.edit_outlined, size: 16), - ), - IconButton( - tooltip: 'Cancel', - onPressed: () => controller.cancelPendingPrompt(item.id), - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 28, - height: 28, - ), - splashRadius: 16, - icon: const Icon(Icons.close, size: 16), - ), - ], - ), - ); - }).toList(), - ), - ); - } - - String _pendingPromptLabel(PendingPrompt item) { - final trimmed = item.text.trim(); - final attachmentCount = item.attachments.length; - if (trimmed.isNotEmpty && attachmentCount == 0) { - return trimmed; - } - if (trimmed.isEmpty && attachmentCount > 0) { - return attachmentCount == 1 - ? '1 attachment' - : '$attachmentCount attachments'; - } - if (attachmentCount > 0) { - return '$trimmed • ${attachmentCount == 1 ? '1 attachment' : '$attachmentCount attachments'}'; - } - return 'Pending message'; - } -} - -class _ComposerAttachmentBar extends StatelessWidget { - const _ComposerAttachmentBar({ - required this.attachments, - required this.onRemove, - }); - - final List attachments; - final ValueChanged onRemove; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: attachments.map((item) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - item.isImage - ? Icons.image_outlined - : Icons.description_outlined, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), - child: Text( - item.fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ), - const SizedBox(width: 4), - IconButton( - tooltip: 'Remove attachment', - onPressed: () => onRemove(item.id), - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close, size: 16), - ), - ], - ), - ), - ); - }).toList(), - ), - ); - } -} - -class _MonospaceOutputView extends StatelessWidget { - const _MonospaceOutputView({ - required this.text, - required this.style, - this.scrollable = true, - }); - - final String text; - final TextStyle? style; - final bool scrollable; - - @override - Widget build(BuildContext context) { - final displayText = _repairDisplayText(text); - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final lineLengths = displayText.split('\n').map((line) => line.length); - final longestLineLength = lineLengths.isEmpty - ? 0 - : lineLengths.reduce((left, right) => left > right ? left : right); - final fontSize = style?.fontSize ?? 12; - final estimatedCharWidth = fontSize * 0.62; - final contentWidth = (longestLineLength * estimatedCharWidth) + 24; - final targetWidth = - constraints.hasBoundedWidth && contentWidth < constraints.maxWidth - ? constraints.maxWidth - : contentWidth; - final content = SizedBox( - width: targetWidth, - child: SelectionArea( - child: Text( - displayText, - overflow: TextOverflow.visible, - softWrap: false, - textWidthBasis: TextWidthBasis.longestLine, - strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.2), - style: style, - ), - ), - ); - if (!scrollable) { - return content; - } - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SingleChildScrollView(child: content), - ); - }, - ); - } - - String _repairDisplayText(String value) { - final lines = value.split('\n'); - if (lines.length < 8) { - return value; - } - - final repaired = []; - final run = []; - - void flushRun() { - if (run.isEmpty) { - return; - } - final nonEmpty = run.where((line) => line.isNotEmpty).toList(); - final mostlySingleChar = - nonEmpty.length >= 6 && - nonEmpty.every((line) { - final trimmed = line.trim(); - return line.runes.length == 1 || trimmed.runes.length == 1; - }); - if (mostlySingleChar) { - final joined = run.join(); - if (repaired.isNotEmpty && joined.startsWith(RegExp(r'\s'))) { - repaired[repaired.length - 1] = '${repaired.last}$joined'; - } else { - repaired.add(joined); - } - } else { - repaired.addAll(run); - } - run.clear(); - } - - for (final line in lines) { - final trimmed = line.trim(); - final isRepairableSingleChar = - line.isEmpty || line.runes.length == 1 || trimmed.runes.length == 1; - if (isRepairableSingleChar) { - run.add(line); - } else { - flushRun(); - repaired.add(line); - } - } - flushRun(); - - return repaired.join('\n'); - } -} - -class _EntryTile extends StatelessWidget { - const _EntryTile({ - required this.entry, - required this.onEditMessage, - required this.onOpenFileReference, - }); - - final ActivityEntry entry; - final ValueChanged onEditMessage; - final Future Function(String path, {int? line}) onOpenFileReference; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final isUserMessage = entry.kind == EntryKind.user; - final isAgentMessage = entry.kind == EntryKind.agent; - final isSystemMessage = entry.kind == EntryKind.system; - final isPendingUserMessage = isUserMessage && entry.isLocalPending; - final normalizedMessageText = - (entry.body.isEmpty ? entry.title : entry.body).trim(); - final isContextCompacting = - isSystemMessage && - normalizedMessageText.toLowerCase().contains('context compact'); - final isCard = - entry.kind == EntryKind.command || - entry.kind == EntryKind.fileChange || - entry.kind == EntryKind.tool; - final systemBorderColor = Color.alphaBlend( - const Color(0xFFFFA24C).withValues(alpha: 0.7), - scheme.outlineVariant, - ); - final systemTextColor = Color.alphaBlend( - const Color(0xFFFFC48A).withValues(alpha: 0.9), - scheme.onSurfaceVariant, - ); - final tone = switch (entry.kind) { - EntryKind.user => scheme.primary.withValues(alpha: 0.16), - EntryKind.agent => theme.colorScheme.surface, - EntryKind.reasoning => Colors.transparent, - EntryKind.command => scheme.surface, - EntryKind.fileChange => scheme.surface, - EntryKind.tool => scheme.surface, - EntryKind.system => const Color(0xFFFFA24C).withValues(alpha: 0.04), - }; - final pendingUserBorderColor = scheme.outlineVariant.withValues(alpha: 0.8); - final pendingUserTextColor = scheme.onSurfaceVariant.withValues( - alpha: 0.58, - ); - - final monospace = - entry.kind == EntryKind.command || - entry.kind == EntryKind.fileChange || - entry.title.contains('MCP') || - entry.body.contains('{') || - entry.body.contains('diff'); - final messageText = normalizedMessageText; - final canEditMessage = - entry.kind == EntryKind.user && messageText.trim().isNotEmpty; - final cardTitle = entry.kind == EntryKind.fileChange - ? _summarizeFileChangeTitle(entry.body, fallback: entry.title) - : entry.title; - - if (isContextCompacting) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Divider( - color: theme.dividerColor, - thickness: 1, - height: 1, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Context Compacting', - style: theme.textTheme.bodySmall?.copyWith( - color: systemTextColor, - fontWeight: FontWeight.w300, - letterSpacing: 0.2, - ), - ), - ), - Expanded( - child: Divider( - color: theme.dividerColor, - thickness: 1, - height: 1, - ), - ), - ], - ), - ); - } - - Widget? bodyContent; - if (isCard && entry.body.isNotEmpty) { - if (entry.kind == EntryKind.fileChange) { - bodyContent = _ExpandableEntryBody( - text: entry.body, - previewText: _collapsedPreviewText(entry.body), - child: _GitDiffView(text: entry.body), - ); - } else if (monospace) { - bodyContent = _MonospaceOutputView( - text: entry.body, - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - height: 1.2, - ), - ); - } else { - bodyContent = SelectableText( - entry.body, - style: theme.textTheme.bodyMedium?.copyWith(height: 1.45), - ); - } - - if (entry.kind == EntryKind.tool || entry.kind == EntryKind.command) { - bodyContent = _ExpandableEntryBody( - text: entry.body, - previewText: _collapsedPreviewText(entry.body), - child: bodyContent, - ); - } - } - - final bubbleContent = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isCard) - Row( - children: [ - Expanded( - child: Text(cardTitle, style: theme.textTheme.titleMedium), - ), - if (entry.status.isNotEmpty) - Text(entry.status, style: theme.textTheme.bodySmall), - ], - ) - else - isAgentMessage - ? _AgentMarkdownMessage( - text: messageText, - style: theme.textTheme.bodyLarge?.copyWith( - height: 1.5, - color: theme.colorScheme.onSurface, - ), - onOpenFileReference: onOpenFileReference, - ) - : _MessageContentText( - text: messageText, - style: theme.textTheme.bodyLarge?.copyWith( - height: 1.5, - color: isSystemMessage - ? systemTextColor - : isPendingUserMessage - ? pendingUserTextColor - : theme.colorScheme.onSurface, - fontWeight: isSystemMessage ? FontWeight.w300 : null, - ), - canEdit: canEditMessage, - onEdit: () => onEditMessage(entry), - onOpenFileReference: onOpenFileReference, - ), - if (entry.secondary.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(entry.secondary, style: theme.textTheme.bodySmall), - ], - if (bodyContent != null) ...[ - const SizedBox(height: 10), - bodyContent, - ], - if (entry.isStreaming) ...[ - const SizedBox(height: 10), - const LinearProgressIndicator(minHeight: 2), - ], - ], - ); - - return Container( - width: double.infinity, - alignment: isUserMessage ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: isUserMessage || isAgentMessage - ? MediaQuery.sizeOf(context).width * 0.84 - : double.infinity, - ), - padding: EdgeInsets.all( - isCard || isUserMessage || isAgentMessage || isSystemMessage ? 14 : 0, - ), - decoration: BoxDecoration( - color: isPendingUserMessage ? Colors.transparent : tone, - border: isPendingUserMessage - ? Border.all(color: pendingUserBorderColor, width: 0.9) - : isSystemMessage - ? Border.all(color: systemBorderColor, width: 1) - : isCard || isAgentMessage - ? Border.all(color: theme.dividerColor) - : null, - borderRadius: BorderRadius.circular(10), - ), - clipBehavior: isPendingUserMessage ? Clip.antiAlias : Clip.none, - child: isPendingUserMessage - ? Stack( - children: [ - Positioned.fill( - child: _PendingMessageSheen( - color: scheme.primary.withValues(alpha: 0.12), - ), - ), - bubbleContent, - ], - ) - : bubbleContent, - ), - ); - } -} - -class _PendingMessageSheen extends StatefulWidget { - const _PendingMessageSheen({required this.color}); - - final Color color; - - @override - State<_PendingMessageSheen> createState() => _PendingMessageSheenState(); -} - -class _PendingMessageSheenState extends State<_PendingMessageSheen> - with SingleTickerProviderStateMixin { - late final AnimationController _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1400), - )..repeat(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IgnorePointer( - key: const ValueKey('pending-message-sheen'), - child: AnimatedBuilder( - animation: _controller, - builder: (BuildContext context, Widget? child) { - final slide = Tween( - begin: -1.2, - end: 1.2, - ).transform(Curves.easeInOut.transform(_controller.value)); - return FractionalTranslation( - translation: Offset(slide, 0), - child: child, - ); - }, - child: Align( - alignment: Alignment.centerLeft, - child: FractionallySizedBox( - widthFactor: 0.5, - heightFactor: 1, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.transparent, - widget.color, - Colors.transparent, - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _MessageContentText extends StatelessWidget { - const _MessageContentText({ - required this.text, - required this.style, - required this.canEdit, - required this.onEdit, - required this.onOpenFileReference, - }); - - final String text; - final TextStyle? style; - final bool canEdit; - final VoidCallback onEdit; - final Future Function(String path, {int? line}) onOpenFileReference; - - @override - Widget build(BuildContext context) { - return SelectableText.rich( - _buildSpans(context), - contextMenuBuilder: - (BuildContext context, EditableTextState editableTextState) { - final items = [ - ...editableTextState.contextMenuButtonItems, - if (canEdit) - ContextMenuButtonItem( - label: 'Edit', - onPressed: () { - ContextMenuController.removeAny(); - onEdit(); - }, - ), - ]; - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: editableTextState.contextMenuAnchors, - buttonItems: items, - ); - }, - style: style, - ); - } - - TextSpan _buildSpans(BuildContext context) { - final matches = <_MessageReferenceMatch>[ - ..._matchMarkdownReferences(text), - ..._matchPlainReferences(text), - ]..sort((left, right) => left.start.compareTo(right.start)); - - final filteredMatches = <_MessageReferenceMatch>[]; - var lastEnd = 0; - for (final match in matches) { - if (match.start < lastEnd) { - continue; - } - filteredMatches.add(match); - lastEnd = match.end; - } - - if (filteredMatches.isEmpty) { - return TextSpan(text: text, style: style); - } - - final linkStyle = style?.copyWith( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ); - final spans = []; - var cursor = 0; - for (final match in filteredMatches) { - if (match.start > cursor) { - spans.add(TextSpan(text: text.substring(cursor, match.start))); - } - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - unawaited(onOpenFileReference(match.path, line: match.line)); - }, - child: Text(match.displayText, style: linkStyle), - ), - ), - ); - cursor = match.end; - } - if (cursor < text.length) { - spans.add(TextSpan(text: text.substring(cursor))); - } - return TextSpan(style: style, children: spans); - } -} - -class _AgentMarkdownMessage extends StatelessWidget { - const _AgentMarkdownMessage({ - required this.text, - required this.style, - required this.onOpenFileReference, - }); - - final String text; - final TextStyle? style; - final Future Function(String path, {int? line}) onOpenFileReference; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final resolvedStyle = style ?? theme.textTheme.bodyLarge; - return SelectionArea( - child: MarkdownBody( - data: _linkifyPlainFileReferences(text), - softLineBreak: true, - onTapLink: (String linkText, String? href, String title) { - if (href == null || href.isEmpty) { - return; - } - final resolved = _parseReferenceTarget(href); - if (resolved == null) { - return; - } - unawaited(onOpenFileReference(resolved.path, line: resolved.line)); - }, - styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( - p: resolvedStyle, - h1: theme.textTheme.headlineSmall?.copyWith( - color: resolvedStyle?.color, - fontWeight: FontWeight.w700, - ), - h2: theme.textTheme.titleLarge?.copyWith( - color: resolvedStyle?.color, - fontWeight: FontWeight.w700, - ), - h3: theme.textTheme.titleMedium?.copyWith( - color: resolvedStyle?.color, - fontWeight: FontWeight.w700, - ), - code: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurface, - ), - codeblockDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.dividerColor), - ), - a: resolvedStyle?.copyWith( - color: theme.colorScheme.primary, - decoration: TextDecoration.underline, - ), - blockquotePadding: const EdgeInsets.only(left: 12), - blockquoteDecoration: BoxDecoration( - border: Border( - left: BorderSide(color: theme.colorScheme.primary, width: 2), - ), - ), - ), - builders: { - 'a': _MarkdownFileLinkBuilder( - onOpenFileReference: onOpenFileReference, - style: resolvedStyle?.copyWith( - color: theme.colorScheme.primary, - decoration: TextDecoration.underline, - ), - ), - }, - ), - ); - } -} - -class _MarkdownFileLinkBuilder extends MarkdownElementBuilder { - _MarkdownFileLinkBuilder({ - required this.onOpenFileReference, - required this.style, - }); - - final Future Function(String path, {int? line}) onOpenFileReference; - final TextStyle? style; - - @override - Widget visitElementAfterWithContext( - BuildContext context, - md.Element element, - TextStyle? preferredStyle, - TextStyle? parentStyle, - ) { - final href = element.attributes['href']; - final resolved = href == null ? null : _parseReferenceTarget(href); - final linkStyle = style ?? preferredStyle ?? parentStyle; - return Text.rich( - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: resolved == null - ? null - : () { - unawaited( - onOpenFileReference(resolved.path, line: resolved.line), - ); - }, - child: Text(element.textContent, style: linkStyle), - ), - ), - ); - } -} - -class _MessageReferenceMatch { - const _MessageReferenceMatch({ - required this.start, - required this.end, - required this.displayText, - required this.path, - required this.line, - }); - - final int start; - final int end; - final String displayText; - final String path; - final int? line; -} - -class _ResolvedReference { - const _ResolvedReference({required this.path, required this.line}); - - final String path; - final int? line; -} - -Iterable<_MessageReferenceMatch> _matchMarkdownReferences(String input) sync* { - final pattern = RegExp(r'\[([^\]]+)\]\(([^)\s]+)\)'); - for (final match in pattern.allMatches(input)) { - final target = match.group(2); - if (target == null || target.isEmpty) { - continue; - } - final resolved = _parseReferenceTarget(target); - if (resolved == null) { - continue; - } - yield _MessageReferenceMatch( - start: match.start, - end: match.end, - displayText: match.group(1) ?? target, - path: resolved.path, - line: resolved.line, - ); - } -} - -Iterable<_MessageReferenceMatch> _matchPlainReferences(String input) sync* { - final pattern = RegExp( - r'(?= 0 ? target.substring(0, hashIndex) : target; - final hash = hashIndex >= 0 ? target.substring(hashIndex + 1) : ''; - int? line; - - final colonMatch = RegExp(r'^(.*):(\d+)$').firstMatch(path); - if (colonMatch != null && - !path.startsWith('ws://') && - !path.startsWith('http://') && - !path.startsWith('https://')) { - path = colonMatch.group(1) ?? path; - line = int.tryParse(colonMatch.group(2) ?? ''); - } - - if (hash.startsWith('L')) { - line = int.tryParse(hash.substring(1)); - } - - if (path.isEmpty) { - return null; - } - return _ResolvedReference(path: path, line: line); -} - -String _linkifyPlainFileReferences(String input) { - final markdownMatches = _matchMarkdownReferences(input).toList(); - final plainMatches = _matchPlainReferences(input).where((plainMatch) { - for (final markdownMatch in markdownMatches) { - if (plainMatch.start >= markdownMatch.start && - plainMatch.end <= markdownMatch.end) { - return false; - } - } - return true; - }).toList()..sort((left, right) => left.start.compareTo(right.start)); - - if (plainMatches.isEmpty) { - return input; - } - - final buffer = StringBuffer(); - var cursor = 0; - for (final match in plainMatches) { - if (match.start < cursor) { - continue; - } - buffer.write(input.substring(cursor, match.start)); - final href = match.line == null - ? match.path - : '${match.path}#L${match.line}'; - buffer.write('[${match.displayText}]($href)'); - cursor = match.end; - } - if (cursor < input.length) { - buffer.write(input.substring(cursor)); - } - return buffer.toString(); -} - -class _ExpandableEntryBody extends StatefulWidget { - const _ExpandableEntryBody({ - required this.text, - required this.child, - this.previewText, - }); - - final String text; - final Widget child; - final String? previewText; - - @override - State<_ExpandableEntryBody> createState() => _ExpandableEntryBodyState(); -} - -class _ExpandableEntryBodyState extends State<_ExpandableEntryBody> { - bool _isExpanded = false; - - bool get _shouldCollapse { - final lines = '\n'.allMatches(widget.text).length + 1; - return lines > 12 || widget.text.length > 900; - } - - @override - Widget build(BuildContext context) { - if (!_shouldCollapse) { - return widget.child; - } - - final theme = Theme.of(context); - final previewText = (widget.previewText ?? widget.text).trim(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AnimatedCrossFade( - firstChild: Stack( - children: [ - SizedBox( - width: double.infinity, - child: Text( - previewText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - height: 1.25, - color: theme.colorScheme.onSurface, - ), - ), - ), - if (previewText.contains('\n') || previewText.length > 120) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: IgnorePointer( - child: Container( - height: 18, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - theme.colorScheme.surface.withValues(alpha: 0), - theme.colorScheme.surface, - ], - ), - ), - ), - ), - ), - ], - ), - secondChild: widget.child, - crossFadeState: _isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 140), - ), - const SizedBox(height: 8), - TextButton.icon( - key: ValueKey( - _isExpanded ? 'entry-body-collapse' : 'entry-body-expand', - ), - onPressed: () { - setState(() { - _isExpanded = !_isExpanded; - }); - }, - icon: Icon(_isExpanded ? Icons.unfold_less : Icons.unfold_more), - label: Text(_isExpanded ? 'Collapse' : 'Expand'), - ), - ], - ); - } -} - -class _GitDiffView extends StatelessWidget { - const _GitDiffView({required this.text}); - - final String text; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final lines = text.split('\n'); - final rows = lines.map((line) => _GitDiffLine.fromRaw(line)).toList(); - final style = theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - height: 1.25, - ); - - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - key: const ValueKey('git-diff-view'), - width: double.infinity, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues( - alpha: 0.35, - ), - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(8), - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: rows.map((row) { - return ColoredBox( - color: row.backgroundColor(theme.colorScheme), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - child: Text( - row.displayText, - style: style?.copyWith( - color: row.foregroundColor(theme.colorScheme), - fontWeight: row.isEmphasized - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - ), - ); - }).toList(), - ), - ), - ), - ), - ); - } -} - -String _collapsedPreviewText(String text) { - final lines = text - .split('\n') - .map((line) => line.trimRight()) - .where((line) => line.isNotEmpty) - .take(2) - .toList(); - if (lines.isEmpty) { - return text.trim(); - } - return lines.join('\n'); -} - -String _summarizeFileChangeTitle(String text, {required String fallback}) { - final lines = text.split('\n'); - String fileName = fallback; - var added = 0; - var removed = 0; - - for (final rawLine in lines) { - final line = rawLine.trim(); - if (line.isEmpty) { - continue; - } - if (fileName == fallback) { - if (line.contains(' • ')) { - fileName = line.split(' • ').first.trim(); - } else if (line.startsWith('+++ ')) { - fileName = line.substring(4).replaceFirst(RegExp(r'^[ab]/'), '').trim(); - } else if (line.startsWith('diff --git ')) { - final parts = line.split(' '); - if (parts.length >= 4) { - fileName = parts[2].replaceFirst(RegExp(r'^[ab]/'), '').trim(); - } - } - } - if (rawLine.startsWith('+') && !rawLine.startsWith('+++ ')) { - added += 1; - } else if (rawLine.startsWith('-') && !rawLine.startsWith('--- ')) { - removed += 1; - } - } - - final segments = [fileName]; - if (added > 0) { - segments.add('+$added'); - } - if (removed > 0) { - segments.add('-$removed'); - } - return segments.join(' '); -} - -class _GitDiffLine { - const _GitDiffLine({required this.displayText, required this.kind}); - - factory _GitDiffLine.fromRaw(String raw) { - if (raw.startsWith('diff --git') || - raw.startsWith('index ') || - raw.startsWith('--- ') || - raw.startsWith('+++ ')) { - return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.header); - } - if (raw.startsWith('@@')) { - return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.hunk); - } - if (raw.startsWith('+')) { - return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.addition); - } - if (raw.startsWith('-')) { - return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.removal); - } - if (raw.contains(' • ')) { - return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.meta); - } - return _GitDiffLine(displayText: raw, kind: _GitDiffLineKind.context); - } - - final String displayText; - final _GitDiffLineKind kind; - - bool get isEmphasized { - return switch (kind) { - _GitDiffLineKind.header || - _GitDiffLineKind.hunk || - _GitDiffLineKind.meta => true, - _GitDiffLineKind.addition || - _GitDiffLineKind.removal || - _GitDiffLineKind.context => false, - }; - } - - Color backgroundColor(ColorScheme scheme) { - return switch (kind) { - _GitDiffLineKind.addition => Colors.green.withValues(alpha: 0.14), - _GitDiffLineKind.removal => Colors.red.withValues(alpha: 0.14), - _GitDiffLineKind.hunk => scheme.tertiary.withValues(alpha: 0.12), - _GitDiffLineKind.meta => scheme.primary.withValues(alpha: 0.08), - _GitDiffLineKind.header => scheme.surfaceContainerHighest.withValues( - alpha: 0.6, - ), - _GitDiffLineKind.context => Colors.transparent, - }; - } - - Color foregroundColor(ColorScheme scheme) { - return switch (kind) { - _GitDiffLineKind.addition => Colors.green.shade800, - _GitDiffLineKind.removal => Colors.red.shade800, - _GitDiffLineKind.hunk => scheme.tertiary, - _GitDiffLineKind.meta => scheme.primary, - _GitDiffLineKind.header => scheme.onSurfaceVariant, - _GitDiffLineKind.context => scheme.onSurface, - }; - } -} - -enum _GitDiffLineKind { meta, header, hunk, addition, removal, context } - -class DownloadCenterPage extends StatelessWidget { - const DownloadCenterPage({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return AnimatedBuilder( - animation: controller, - builder: (BuildContext context, Widget? child) { - return Scaffold( - appBar: AppBar( - title: const Text('Downloads'), - actions: [ - TextButton( - onPressed: - controller.downloadRecords.any( - (item) => item.state != DownloadState.running, - ) - ? controller.clearFinishedDownloads - : null, - child: const Text('Clear finished'), - ), - ], - ), - body: SafeArea( - child: controller.downloadRecords.isEmpty - ? Center( - child: Text( - 'No downloads yet.', - style: theme.textTheme.bodyMedium, - ), - ) - : ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: controller.downloadRecords.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (BuildContext context, int index) { - final record = controller.downloadRecords[index]; - return _DownloadRecordTile( - record: record, - onOpen: record.targetPath == null - ? null - : () => openDownloadedLocation( - context, - record.targetPath!, - ), - onCancel: record.state == DownloadState.running - ? () => controller.cancelFileDownload( - record.sourcePath, - ) - : null, - ); - }, - ), - ), - ); - }, - ); - } -} - -class AutomationPage extends StatefulWidget { - const AutomationPage({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => _AutomationPageState(); -} - -class _AutomationPageState extends State { - bool _showAllAutomations = false; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.controller, - builder: (BuildContext context, Widget? child) { - final theme = Theme.of(context); - final visibleAutomations = _showAllAutomations - ? widget.controller.automations - : widget.controller.automations - .where(widget.controller.isAutomationVisibleInCurrentThread) - .toList(growable: false); - final emptyMessage = _showAllAutomations - ? 'Create automations from nodes: a filesystem watch trigger followed by sequential actions like download, install APK, or run a command.' - : 'No automations are scoped to the current thread yet.'; - return Scaffold( - appBar: AppBar( - title: const Text('Automations'), - actions: [ - TextButton.icon( - onPressed: () { - setState(() { - _showAllAutomations = !_showAllAutomations; - }); - }, - icon: Icon( - _showAllAutomations ? Icons.list : Icons.filter_alt, - size: 18, - ), - label: Text(_showAllAutomations ? 'All' : 'Current'), - ), - IconButton( - tooltip: 'New automation', - onPressed: () => _openEditor(context), - icon: const Icon(Icons.add), - ), - ], - ), - body: visibleAutomations.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Text( - emptyMessage, - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, - ), - ), - ) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - itemBuilder: (BuildContext context, int index) { - final automation = visibleAutomations[index]; - final ownerThreadId = automation.ownerThreadId.trim(); - final currentThreadId = widget - .controller - .currentAutomationScopeThreadId - .trim(); - final canCopyToCurrentThread = - ownerThreadId.isNotEmpty && - currentThreadId.isNotEmpty && - ownerThreadId != currentThreadId; - return _AutomationCard( - automation: automation, - isRunning: widget.controller.isAutomationRunning( - automation.id, - ), - onToggleEnabled: (value) { - widget.controller.setAutomationEnabled( - automation.id, - value, - ); - }, - onEdit: () => - _openEditor(context, automation: automation), - onCopyToCurrentThread: canCopyToCurrentThread - ? () => widget.controller - .copyAutomationToCurrentThread(automation.id) - : null, - onDelete: () => - widget.controller.deleteAutomation(automation.id), - ); - }, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemCount: visibleAutomations.length, - ), - ); - }, - ); - } - - Future _openEditor( - BuildContext context, { - AutomationDefinition? automation, - }) async { - await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return AutomationEditorPage( - controller: widget.controller, - initialAutomation: - automation ?? - AutomationDefinition( - id: 'automation-${DateTime.now().microsecondsSinceEpoch}', - name: '', - enabled: true, - nodes: const [], - ), - ); - }, - ), - ); - } -} - -class _AutomationCard extends StatelessWidget { - const _AutomationCard({ - required this.automation, - required this.isRunning, - required this.onToggleEnabled, - required this.onEdit, - required this.onCopyToCurrentThread, - required this.onDelete, - }); - - final AutomationDefinition automation; - final bool isRunning; - final ValueChanged onToggleEnabled; - final VoidCallback onEdit; - final VoidCallback? onCopyToCurrentThread; - final VoidCallback onDelete; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final trigger = automation.triggerNode; - final actions = automation.actionNodes; - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - automation.name.trim().isEmpty - ? 'Untitled automation' - : automation.name, - style: theme.textTheme.titleMedium, - ), - ), - Switch(value: automation.enabled, onChanged: onToggleEnabled), - ], - ), - const SizedBox(height: 8), - Text( - trigger == null - ? 'No trigger configured' - : trigger.path.trim().isEmpty - ? trigger.kind.title - : '${trigger.kind.title} • ${trigger.path}', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 6), - Text( - actions.isEmpty - ? 'No actions configured' - : actions.map((node) => node.kind.title).join(' → '), - style: theme.textTheme.bodyMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: isRunning - ? theme.colorScheme.primary.withValues(alpha: 0.12) - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - isRunning - ? 'Running' - : automation.enabled - ? 'Enabled' - : 'Disabled', - style: theme.textTheme.labelSmall?.copyWith( - color: isRunning - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - const Spacer(), - TextButton(onPressed: onEdit, child: const Text('Edit')), - if (onCopyToCurrentThread != null) - TextButton( - onPressed: onCopyToCurrentThread, - child: const Text('Copy'), - ), - TextButton(onPressed: onDelete, child: const Text('Delete')), - ], - ), - ], - ), - ), - ); - } -} - -class AutomationEditorPage extends StatefulWidget { - const AutomationEditorPage({ - super.key, - required this.controller, - required this.initialAutomation, - }); - - final AppController controller; - final AutomationDefinition initialAutomation; - - @override - State createState() => _AutomationEditorPageState(); -} - -class _AutomationEditorPageState extends State { - late final TextEditingController _nameController; - late bool _enabled; - late List _nodes; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController( - text: widget.initialAutomation.name, - ); - _enabled = widget.initialAutomation.enabled; - _nodes = List.from(widget.initialAutomation.nodes); - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final trigger = _nodes.where((node) => node.kind.isTrigger).toList(); - final actions = _nodes.where((node) => !node.kind.isTrigger).toList(); - return Scaffold( - appBar: AppBar( - title: const Text('Automation'), - actions: [ - TextButton(onPressed: _saveAutomation, child: const Text('Save')), - ], - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - children: [ - TextField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Automation name'), - ), - const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Enabled'), - value: _enabled, - onChanged: (value) { - setState(() { - _enabled = value; - }); - }, - ), - const SizedBox(height: 16), - Row( - children: [ - Text('Nodes', style: theme.textTheme.titleLarge), - const Spacer(), - if (trigger.isEmpty) - OutlinedButton.icon( - onPressed: () => _addNode(isTrigger: true), - icon: const Icon(Icons.flash_on_outlined), - label: const Text('Add trigger'), - ) - else - OutlinedButton.icon( - onPressed: () => _addNode(isTrigger: false), - icon: const Icon(Icons.add), - label: const Text('Add action'), - ), - ], - ), - const SizedBox(height: 12), - if (_nodes.isEmpty) - Text( - 'Start with a trigger, then add sequential action and control nodes.', - style: theme.textTheme.bodyMedium, - ) - else - ..._nodes.asMap().entries.map((entry) { - final index = entry.key; - final node = entry.value; - return Padding( - padding: EdgeInsets.only( - bottom: index == _nodes.length - 1 ? 0 : 10, - ), - child: _AutomationNodeCard( - index: index, - node: node, - onEdit: () => _editNode(index), - onDelete: () { - setState(() { - _nodes.removeAt(index); - }); - }, - ), - ); - }), - if (actions.isNotEmpty) ...[ - const SizedBox(height: 18), - Text( - 'Sequential actions run in the order shown above.', - style: theme.textTheme.bodySmall, - ), - ], - ], - ), - ), - ); - } - - Future _addNode({required bool isTrigger}) async { - final kind = await showModalBottomSheet( - context: context, - builder: (BuildContext context) { - final options = isTrigger - ? const [ - AutomationNodeKind.watchFileChanged, - AutomationNodeKind.watchDirectoryChanged, - AutomationNodeKind.turnCompleted, - ] - : const [ - AutomationNodeKind.didPathChangeSinceLastRun, - AutomationNodeKind.ifElse, - AutomationNodeKind.quit, - AutomationNodeKind.downloadChangedFile, - AutomationNodeKind.installDownloadedApk, - AutomationNodeKind.sendMessageToCurrentThread, - AutomationNodeKind.runCommand, - ]; - return SafeArea( - child: Wrap( - children: options - .map( - (kind) => ListTile( - leading: Icon(kind.icon), - title: Text(kind.title), - onTap: () => Navigator.of(context).pop(kind), - ), - ) - .toList(), - ), - ); - }, - ); - if (kind == null || !mounted) { - return; - } - final draft = AutomationNode( - id: 'node-${DateTime.now().microsecondsSinceEpoch}', - kind: kind, - ); - final edited = await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return AutomationNodeEditorPage( - controller: widget.controller, - node: draft, - ); - }, - ), - ); - if (edited == null) { - return; - } - setState(() { - if (kind.isTrigger) { - _nodes.removeWhere((node) => node.kind.isTrigger); - _nodes.insert(0, edited); - } else { - _nodes.add(edited); - } - }); - } - - Future _editNode(int index) async { - final edited = await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return AutomationNodeEditorPage( - controller: widget.controller, - node: _nodes[index], - ); - }, - ), - ); - if (edited == null) { - return; - } - setState(() { - _nodes[index] = edited; - if (edited.kind.isTrigger) { - final triggerIndex = _nodes.indexWhere((node) => node.id == edited.id); - if (triggerIndex > 0) { - final trigger = _nodes.removeAt(triggerIndex); - _nodes.insert(0, trigger); - } - } - }); - } - - Future _saveAutomation() async { - final name = _nameController.text.trim(); - final hasTrigger = _nodes.any((node) => node.kind.isTrigger); - final hasAction = _nodes.any((node) => !node.kind.isTrigger); - if (name.isEmpty || !hasTrigger || !hasAction) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Automation needs a name, one trigger, and at least one action.', - ), - ), - ); - return; - } - await widget.controller.saveAutomation( - widget.initialAutomation.copyWith( - name: name, - enabled: _enabled, - nodes: List.from(_nodes), - ), - ); - if (!mounted) { - return; - } - Navigator.of(context).pop(); - } -} - -class _AutomationNodeCard extends StatelessWidget { - const _AutomationNodeCard({ - required this.index, - required this.node, - required this.onEdit, - required this.onDelete, - }); - - final int index; - final AutomationNode node; - final VoidCallback onEdit; - final VoidCallback onDelete; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - leading: CircleAvatar( - radius: 14, - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12), - child: Text('${index + 1}', style: theme.textTheme.labelSmall), - ), - title: Text(node.kind.title), - subtitle: Text( - _automationNodeSummary(node), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - onTap: onEdit, - trailing: IconButton( - tooltip: 'Delete node', - onPressed: onDelete, - icon: const Icon(Icons.close), - ), - ), - ); - } -} - -class AutomationNodeEditorPage extends StatefulWidget { - const AutomationNodeEditorPage({ - super.key, - required this.controller, - required this.node, - }); - - final AppController controller; - final AutomationNode node; - - @override - State createState() => - _AutomationNodeEditorPageState(); -} - -class _AutomationNodeEditorPageState extends State { - late final TextEditingController _pathController; - late final TextEditingController _commandController; - late final TextEditingController _cwdController; - late final TextEditingController _directoryController; - late final TextEditingController _conditionTokenController; - late AutomationBranchOutcome _whenTrue; - late AutomationBranchOutcome _whenFalse; - - @override - void initState() { - super.initState(); - _pathController = TextEditingController(text: widget.node.path); - _commandController = TextEditingController(text: widget.node.commandText); - _cwdController = TextEditingController(text: widget.node.cwd); - _directoryController = TextEditingController(text: widget.node.directory); - _conditionTokenController = TextEditingController( - text: widget.node.conditionToken, - ); - _whenTrue = widget.node.whenTrue; - _whenFalse = widget.node.whenFalse; - } - - @override - void dispose() { - _pathController.dispose(); - _commandController.dispose(); - _cwdController.dispose(); - _directoryController.dispose(); - _conditionTokenController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final node = widget.node; - return Scaffold( - appBar: AppBar( - title: Text(node.kind.title), - actions: [ - TextButton(onPressed: _saveNode, child: const Text('Save')), - ], - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - children: [ - if (node.kind == AutomationNodeKind.watchFileChanged || - node.kind == - AutomationNodeKind.watchDirectoryChanged) ...[ - Row( - children: [ - Expanded( - child: TextField( - controller: _pathController, - decoration: InputDecoration( - labelText: - node.kind == - AutomationNodeKind.watchDirectoryChanged - ? 'Folder path' - : 'File path', - hintText: - node.kind == - AutomationNodeKind.watchDirectoryChanged - ? '/workspace/app' - : '/workspace/app/build/app-release.apk', - ), - ), - ), - const SizedBox(width: 8), - IconButton( - tooltip: 'Browse remote files', - onPressed: () => _browseForPath( - node.kind, - allowDirectorySelection: - node.kind == AutomationNodeKind.watchDirectoryChanged, - allowFileSelection: - node.kind != AutomationNodeKind.watchDirectoryChanged, - ), - icon: const Icon(Icons.folder_open_outlined), - ), - ], - ), - ], - if (node.kind == AutomationNodeKind.turnCompleted) ...[ - const Text( - 'Triggers after the app-server reports an LLM turn completed. No filesystem path is required.', - ), - const SizedBox(height: 8), - Text( - 'Use this with actions like Run command to start follow-up automation after a response finishes.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (node.kind == AutomationNodeKind.watchFileChanged || - node.kind == - AutomationNodeKind.watchDirectoryChanged) ...[ - const SizedBox(height: 8), - Text( - 'Pick the trigger target from the remote file explorer.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (node.kind == - AutomationNodeKind.didPathChangeSinceLastRun) ...[ - Row( - children: [ - Expanded( - child: TextField( - controller: _pathController, - decoration: const InputDecoration( - labelText: 'File or folder path', - hintText: '/workspace/app/build/app-release.apk', - ), - ), - ), - const SizedBox(width: 8), - IconButton( - tooltip: 'Browse remote files', - onPressed: () => _browseForPath( - node.kind, - allowDirectorySelection: true, - allowFileSelection: true, - ), - icon: const Icon(Icons.folder_open_outlined), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Compares the selected file or folder against the previous execution of this automation and stores {{previous.changed}} for the next node.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (node.kind == AutomationNodeKind.ifElse) ...[ - TextField( - controller: _conditionTokenController, - decoration: const InputDecoration( - labelText: 'Condition value or template', - hintText: '{{previous.changed}}', - ), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _whenTrue, - decoration: const InputDecoration(labelText: 'When true'), - items: AutomationBranchOutcome.values - .map( - (value) => DropdownMenuItem( - value: value, - child: Text(_branchOutcomeLabel(value)), - ), - ) - .toList(growable: false), - onChanged: (value) { - if (value == null) { - return; - } - setState(() { - _whenTrue = value; - }); - }, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _whenFalse, - decoration: const InputDecoration(labelText: 'When false'), - items: AutomationBranchOutcome.values - .map( - (value) => DropdownMenuItem( - value: value, - child: Text(_branchOutcomeLabel(value)), - ), - ) - .toList(growable: false), - onChanged: (value) { - if (value == null) { - return; - } - setState(() { - _whenFalse = value; - }); - }, - ), - const SizedBox(height: 8), - Text( - 'Defaults to {{previous.changed}} so it can branch after a Did file or folder change node.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (node.kind == AutomationNodeKind.quit) ...[ - const Text( - 'Stops the automation immediately when this node is reached.', - ), - ], - if (node.kind == - AutomationNodeKind.downloadChangedFile) ...[ - Text( - 'Downloads the file path reported by the trigger. If no explicit directory is set here, the automation uses the remembered download directory for the active thread.', - ), - const SizedBox(height: 8), - Text( - 'Optional templates: {{trigger.changedPath}}, {{previous.downloadedPath}}, {{node.someId.downloadedPath}}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - TextField( - controller: _directoryController, - decoration: const InputDecoration( - labelText: 'Download directory (optional)', - hintText: '/storage/emulated/0/Download', - ), - ), - ], - if (node.kind == - AutomationNodeKind.installDownloadedApk) ...[ - const Text( - 'Opens the downloaded APK with the system installer. By default it uses the previous download node output.', - ), - const SizedBox(height: 8), - Text( - 'Optional path override or template: {{previous.downloadedPath}}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - TextField( - controller: _pathController, - decoration: const InputDecoration( - labelText: 'APK path override (optional)', - hintText: '{{previous.downloadedPath}}', - ), - ), - ], - if (node.kind == - AutomationNodeKind.sendMessageToCurrentThread) ...[ - TextField( - controller: _commandController, - decoration: const InputDecoration( - labelText: 'Message', - hintText: 'A new APK build is ready.', - ), - maxLines: 4, - minLines: 2, - ), - const SizedBox(height: 8), - Text( - 'Templates: {{trigger.changedPath}}, {{previous.downloadedPath}}, {{previous.stdout}}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (node.kind == AutomationNodeKind.runCommand) ...[ - TextField( - controller: _commandController, - decoration: const InputDecoration( - labelText: 'Command', - hintText: 'flutter build apk --release', - ), - maxLines: 3, - minLines: 1, - ), - const SizedBox(height: 12), - TextField( - controller: _cwdController, - decoration: const InputDecoration( - labelText: 'Working directory (optional)', - hintText: '/workspace/app', - ), - ), - const SizedBox(height: 8), - Text( - 'Templates: {{trigger.changedPath}}, {{trigger.path}}, {{previous.stdout}}, {{previous.downloadedPath}}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ], - ), - ), - ); - } - - void _saveNode() { - final next = widget.node.copyWith( - path: _pathController.text.trim(), - commandText: _commandController.text.trim(), - cwd: _cwdController.text.trim(), - directory: _directoryController.text.trim(), - conditionToken: _conditionTokenController.text.trim(), - whenTrue: _whenTrue, - whenFalse: _whenFalse, - ); - final needsPath = - next.kind == AutomationNodeKind.watchFileChanged || - next.kind == AutomationNodeKind.watchDirectoryChanged || - next.kind == AutomationNodeKind.didPathChangeSinceLastRun; - final needsCommand = - next.kind == AutomationNodeKind.runCommand || - next.kind == AutomationNodeKind.sendMessageToCurrentThread; - if (needsPath && next.path.isEmpty) { - _showValidation('An absolute path is required.'); - return; - } - if (needsCommand && next.commandText.isEmpty) { - _showValidation( - next.kind == AutomationNodeKind.sendMessageToCurrentThread - ? 'A message is required.' - : 'A command is required.', - ); - return; - } - Navigator.of(context).pop(next); - } - - Future _browseForPath( - AutomationNodeKind kind, { - bool allowDirectorySelection = false, - bool allowFileSelection = true, - }) async { - final selectedPath = await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return AutomationPathPickerPage( - controller: widget.controller, - allowDirectorySelection: allowDirectorySelection, - allowFileSelection: allowFileSelection, - title: allowDirectorySelection && allowFileSelection - ? 'Select file or folder' - : kind == AutomationNodeKind.watchDirectoryChanged - ? 'Select watched folder' - : 'Select watched file', - initialPath: _pathController.text.trim(), - ); - }, - ), - ); - if (selectedPath == null) { - return; - } - _pathController - ..text = selectedPath - ..selection = TextSelection.collapsed(offset: selectedPath.length); - } - - void _showValidation(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); - } -} - -class AutomationPathPickerPage extends StatefulWidget { - const AutomationPathPickerPage({ - super.key, - required this.controller, - required this.allowDirectorySelection, - required this.allowFileSelection, - required this.title, - this.initialPath, - }); - - final AppController controller; - final bool allowDirectorySelection; - final bool allowFileSelection; - final String title; - final String? initialPath; - - @override - State createState() => - _AutomationPathPickerPageState(); -} - -class _AutomationPathPickerPageState extends State { - late final TextEditingController _pathController; - - @override - void initState() { - super.initState(); - final initial = widget.initialPath?.trim(); - final initialDirectory = _initialDirectory(initial); - _pathController = TextEditingController(text: initialDirectory); - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(widget.controller.loadDirectory(initialDirectory)); - }); - } - - @override - void dispose() { - _pathController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.controller, - builder: (BuildContext context, Widget? child) { - final controller = widget.controller; - final theme = Theme.of(context); - if (_pathController.text != controller.fileBrowserPath && - controller.fileBrowserPath.isNotEmpty) { - _pathController.value = _pathController.value.copyWith( - text: controller.fileBrowserPath, - selection: TextSelection.collapsed( - offset: controller.fileBrowserPath.length, - ), - ); - } - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - actions: [ - if (widget.allowDirectorySelection) - TextButton( - onPressed: controller.fileBrowserPath.trim().isEmpty - ? null - : () => Navigator.of( - context, - ).pop(controller.fileBrowserPath), - child: const Text('Select'), - ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - child: Column( - children: [ - Row( - children: [ - IconButton( - tooltip: 'Up', - onPressed: controller.fileBrowserPath == '/' - ? null - : controller.navigateToParentDirectory, - icon: const Icon(Icons.arrow_upward), - ), - Expanded( - child: TextField( - controller: _pathController, - decoration: const InputDecoration( - labelText: 'Absolute path', - ), - onSubmitted: controller.loadDirectory, - ), - ), - const SizedBox(width: 8), - OutlinedButton( - onPressed: controller.isLoadingFiles - ? null - : () => controller.loadDirectory( - _pathController.text, - ), - child: const Text('Open'), - ), - ], - ), - if (controller.fileBrowserError != null) ...[ - const SizedBox(height: 8), - Text( - controller.fileBrowserError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - const SizedBox(height: 12), - Expanded( - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(12), - ), - child: - controller.isLoadingFiles && - controller.fileBrowserEntries.isEmpty - ? const Center(child: CircularProgressIndicator()) - : ListView.separated( - padding: const EdgeInsets.all(12), - itemCount: controller.fileBrowserEntries.length, - separatorBuilder: (_, _) => - const SizedBox(height: 8), - itemBuilder: (BuildContext context, int index) { - final entry = - controller.fileBrowserEntries[index]; - final fullPath = controller.joinFileBrowserPath( - entry.fileName, - ); - return ListTile( - dense: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - tileColor: - theme.colorScheme.surfaceContainerLow, - leading: Icon( - entry.isDirectory - ? Icons.folder_outlined - : Icons.insert_drive_file_outlined, - ), - title: Text( - entry.fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () async { - if (entry.isDirectory) { - await controller.loadDirectory(fullPath); - return; - } - if (widget.allowFileSelection && - entry.isFile) { - if (!mounted) { - return; - } - Navigator.of(context).pop(fullPath); - } - }, - ); - }, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); - } - - String _initialDirectory(String? initialPath) { - if (initialPath == null || initialPath.isEmpty) { - return widget.controller.preferredFileBrowserRoot; - } - if (widget.allowDirectorySelection) { - return initialPath; - } - final slashIndex = initialPath.lastIndexOf('/'); - if (slashIndex <= 0) { - return '/'; - } - return initialPath.substring(0, slashIndex); - } -} - -String _automationNodeSummary(AutomationNode node) { - switch (node.kind) { - case AutomationNodeKind.watchFileChanged: - case AutomationNodeKind.watchDirectoryChanged: - return node.path.trim().isEmpty ? 'No path configured' : node.path.trim(); - case AutomationNodeKind.turnCompleted: - return 'Runs after an LLM turn completes.'; - case AutomationNodeKind.didPathChangeSinceLastRun: - return node.path.trim().isEmpty - ? 'Compare a file or folder against the previous automation run' - : 'Compare ${node.path.trim()} against the previous automation run'; - case AutomationNodeKind.ifElse: - final condition = node.conditionToken.trim().isEmpty - ? '{{previous.changed}}' - : node.conditionToken.trim(); - return 'If $condition → ${_branchOutcomeLabel(node.whenTrue)} / ${_branchOutcomeLabel(node.whenFalse)}'; - case AutomationNodeKind.quit: - return 'Stop the automation immediately.'; - case AutomationNodeKind.downloadChangedFile: - return node.directory.trim().isEmpty - ? 'Download to remembered thread directory' - : 'Download to ${node.directory.trim()}'; - case AutomationNodeKind.installDownloadedApk: - return 'Install the APK that was downloaded by an earlier node.'; - case AutomationNodeKind.sendMessageToCurrentThread: - final message = node.commandText.trim(); - return message.isEmpty ? 'No message configured' : message; - case AutomationNodeKind.runCommand: - final command = node.commandText.trim(); - final cwd = node.cwd.trim(); - if (command.isEmpty) { - return 'No command configured'; - } - if (cwd.isEmpty) { - return command; - } - return '$command • $cwd'; - } -} - -String _branchOutcomeLabel(AutomationBranchOutcome value) { - return switch (value) { - AutomationBranchOutcome.continueFlow => 'Continue', - AutomationBranchOutcome.quitFlow => 'Quit', - }; -} - -class _DownloadRecordTile extends StatelessWidget { - const _DownloadRecordTile({required this.record, this.onOpen, this.onCancel}); - - final DownloadRecord record; - final VoidCallback? onOpen; - final VoidCallback? onCancel; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final status = record.status; - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(_downloadStateIcon(record.state), size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - record.fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, - ), - ), - Text( - _downloadStateLabel(record.state), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - record.sourcePath, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (status != null) ...[ - const SizedBox(height: 10), - LinearProgressIndicator( - value: record.state == DownloadState.running - ? status.progress - : 1, - ), - const SizedBox(height: 6), - Text(_formatTransferSize(status), style: theme.textTheme.bodySmall), - const SizedBox(height: 2), - Text( - record.state == DownloadState.running - ? _formatEta(status) - : _downloadCompletionText(record), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ] else if (record.error != null && - record.error!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - record.error!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - const SizedBox(height: 10), - Row( - children: [ - if (record.targetPath != null && onOpen != null) - OutlinedButton.icon( - onPressed: onOpen, - icon: const Icon(Icons.folder_open_outlined, size: 18), - label: const Text('Open'), - ), - if (record.state == DownloadState.running && - onCancel != null) ...[ - if (record.targetPath != null) const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, size: 18), - label: const Text('Cancel'), - ), - ], - ], - ), - ], - ), - ); - } -} - -IconData _downloadStateIcon(DownloadState state) { - return switch (state) { - DownloadState.running => Icons.downloading_rounded, - DownloadState.completed => Icons.download_done_outlined, - DownloadState.failed => Icons.error_outline, - DownloadState.cancelled => Icons.remove_circle_outline, - }; -} - -String _downloadStateLabel(DownloadState state) { - return switch (state) { - DownloadState.running => 'Downloading', - DownloadState.completed => 'Completed', - DownloadState.failed => 'Failed', - DownloadState.cancelled => 'Cancelled', - }; -} - -String _downloadCompletionText(DownloadRecord record) { - final finishedAt = record.finishedAt; - if (finishedAt == null) { - return ''; - } - final local = finishedAt; - final hour = local.hour.toString().padLeft(2, '0'); - final minute = local.minute.toString().padLeft(2, '0'); - return 'Finished at $hour:$minute'; -} - -class SettingsPage extends StatefulWidget { - const SettingsPage({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => _SettingsPageState(); -} - -class _SettingsPageState extends State { - late ConnectionMode _connectionMode; - late final TextEditingController _serverController; - late final TextEditingController _websocketBearerTokenController; - late final TextEditingController _relayUrlController; - late final TextEditingController _pairingCodeController; - late ThemePreference _themePreference; - late SandboxMode _sandboxMode; - late String _approvalPolicy; - late bool _allowNetwork; - bool _isPairing = false; - String? _pairingError; - String? _pairingSuccess; - - @override - void initState() { - super.initState(); - final settings = widget.controller.settings; - _connectionMode = settings.connectionMode; - _serverController = TextEditingController(text: settings.serverUrl); - _websocketBearerTokenController = TextEditingController( - text: settings.websocketBearerToken, - ); - _relayUrlController = TextEditingController(text: settings.relayUrl); - _pairingCodeController = TextEditingController(); - _themePreference = settings.themePreference; - _sandboxMode = settings.sandboxMode; - _approvalPolicy = settings.approvalPolicy; - _allowNetwork = settings.allowNetwork; - } - - @override - void dispose() { - _serverController.dispose(); - _websocketBearerTokenController.dispose(); - _relayUrlController.dispose(); - _pairingCodeController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar(title: const Text('Settings')), - body: SafeArea( - top: false, - child: SingleChildScrollView( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownButtonFormField( - initialValue: _connectionMode, - decoration: const InputDecoration(labelText: 'Connection mode'), - items: ConnectionMode.values.map((ConnectionMode value) { - return DropdownMenuItem( - value: value, - child: Text(value.name), - ); - }).toList(), - onChanged: (ConnectionMode? value) { - if (value != null) { - setState(() { - _connectionMode = value; - }); - } - }, - ), - const SizedBox(height: 12), - if (_connectionMode == ConnectionMode.direct) ...[ - TextField( - controller: _serverController, - decoration: const InputDecoration( - labelText: 'Websocket URL', - hintText: 'ws://192.168.1.20:8080', - ), - ), - const SizedBox(height: 12), - TextField( - controller: _websocketBearerTokenController, - autocorrect: false, - enableSuggestions: false, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Websocket bearer token', - hintText: 'Optional Authorization: Bearer token', - helperText: - 'Sent during the websocket handshake when app-server auth is enabled.', - ), - ), - ] else ...[ - TextField( - controller: _relayUrlController, - decoration: const InputDecoration( - labelText: 'Relay URL', - hintText: 'https://relay.example.com', - ), - ), - const SizedBox(height: 12), - TextField( - controller: _pairingCodeController, - minLines: 2, - maxLines: 4, - decoration: const InputDecoration( - labelText: 'Pairing code', - hintText: 'crp1....', - helperText: - 'Paste the pairing code or scan the QR shown by codex-remote-cli.', - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _isPairing ? null : _scanRelayQrCode, - icon: const Icon(Icons.qr_code_scanner), - label: const Text('Scan QR code'), - ), - ), - if (widget.controller.settings.relayDeviceId.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - 'Paired bridge: ${widget.controller.settings.relayBridgeLabel.isEmpty ? widget.controller.settings.relayDeviceId : widget.controller.settings.relayBridgeLabel}', - style: theme.textTheme.bodySmall, - ), - ), - if (_pairingError != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _pairingError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ), - if (_pairingSuccess != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _pairingSuccess!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _isPairing ? null : _pairRelayDevice, - child: Text(_isPairing ? 'Pairing...' : 'Pair device'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton( - onPressed: - widget.controller.settings.relayDeviceId.isEmpty - ? null - : _clearRelayPairing, - child: const Text('Clear pairing'), - ), - ), - ], - ), - ], - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _themePreference, - decoration: const InputDecoration(labelText: 'Theme'), - items: ThemePreference.values.map((item) { - return DropdownMenuItem( - value: item, - child: Text(item.name), - ); - }).toList(), - onChanged: (ThemePreference? value) { - if (value != null) { - setState(() => _themePreference = value); - } - }, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _sandboxMode, - decoration: const InputDecoration(labelText: 'Sandbox'), - items: SandboxMode.values.map((item) { - return DropdownMenuItem( - value: item, - child: Text(item.name), - ); - }).toList(), - onChanged: (SandboxMode? value) { - if (value != null) { - setState(() => _sandboxMode = value); - } - }, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _approvalPolicy, - decoration: const InputDecoration(labelText: 'Approval policy'), - items: - const [ - 'untrusted', - 'on-request', - 'on-failure', - 'never', - ].map((item) { - return DropdownMenuItem( - value: item, - child: Text(item), - ); - }).toList(), - onChanged: (String? value) { - if (value != null) { - setState(() => _approvalPolicy = value); - } - }, - ), - const SizedBox(height: 12), - SwitchListTile.adaptive( - value: _allowNetwork, - contentPadding: EdgeInsets.zero, - title: const Text('Allow network in workspace-write mode'), - onChanged: (bool value) { - setState(() => _allowNetwork = value); - }, - ), - const SizedBox(height: 8), - Text( - 'Last thread: ${widget.controller.settings.resumeThreadId.isEmpty ? 'none' : widget.controller.settings.resumeThreadId}', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: _save, - child: const Text('Save'), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Future _save() async { - final nextSettings = widget.controller.settings.copyWith( - connectionMode: _connectionMode, - serverUrl: _serverController.text.trim(), - websocketBearerToken: _websocketBearerTokenController.text.trim(), - relayUrl: _relayUrlController.text.trim(), - themePreference: _themePreference, - sandboxMode: _sandboxMode, - approvalPolicy: _approvalPolicy, - allowNetwork: _allowNetwork, - ); - await widget.controller.reconnectWithSettings(nextSettings); - if (mounted) { - Navigator.of(context).pop(); - } - } - - Future _pairRelayDevice() async { - await _pairRelayDeviceWithCode(_pairingCodeController.text); - } - - Future _pairRelayDeviceWithCode(String pairingCode) async { - setState(() { - _isPairing = true; - _pairingError = null; - _pairingSuccess = null; - }); - try { - await widget.controller.pairRelayDevice(pairingCode: pairingCode); - _relayUrlController.text = widget.controller.settings.relayUrl; - _pairingCodeController.clear(); - setState(() { - _pairingSuccess = 'Device paired successfully.'; - _connectionMode = ConnectionMode.relay; - }); - } catch (error) { - setState(() { - _pairingError = error.toString(); - }); - } finally { - if (mounted) { - setState(() { - _isPairing = false; - }); - } - } - } - - Future _scanRelayQrCode() async { - final scannedCode = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const _RelayPairingQrScannerPage(), - fullscreenDialog: true, - ), - ); - if (!mounted || scannedCode == null || scannedCode.isEmpty) { - return; - } - _pairingCodeController.text = scannedCode; - await _pairRelayDeviceWithCode(scannedCode); - } - - Future _clearRelayPairing() async { - await widget.controller.clearRelayPairing(); - _relayUrlController.clear(); - setState(() { - _pairingError = null; - _pairingSuccess = null; - _connectionMode = ConnectionMode.direct; - }); - } -} - -class _RelayPairingQrScannerPage extends StatefulWidget { - const _RelayPairingQrScannerPage(); - - @override - State<_RelayPairingQrScannerPage> createState() => - _RelayPairingQrScannerPageState(); -} - -class _RelayPairingQrScannerPageState - extends State<_RelayPairingQrScannerPage> { - final MobileScannerController _controller = MobileScannerController(); - bool _handledCode = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar(title: const Text('Scan Pairing QR')), - body: Stack( - children: [ - MobileScanner( - controller: _controller, - onDetect: (BarcodeCapture capture) { - if (_handledCode) { - return; - } - for (final barcode in capture.barcodes) { - final rawValue = barcode.rawValue?.trim() ?? ''; - if (!rawValue.startsWith('crp1.')) { - continue; - } - _handledCode = true; - _controller.stop(); - Navigator.of(context).pop(rawValue); - return; - } - }, - ), - Positioned( - left: 20, - right: 20, - bottom: 24, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Point the camera at the relay pairing QR code.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -class EventLogSheet extends StatelessWidget { - const EventLogSheet({super.key, required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Event log', style: theme.textTheme.titleLarge), - const SizedBox(height: 12), - SizedBox( - height: MediaQuery.sizeOf(context).height * 0.6, - child: ListView.separated( - itemCount: controller.eventLog.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (BuildContext context, int index) { - final entry = controller.eventLog[index]; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(entry.method, style: theme.textTheme.titleMedium), - if (entry.summary.isNotEmpty) ...[ - const SizedBox(height: 6), - SelectableText( - entry.summary, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ], - ], - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class CommandCenterPage extends StatefulWidget { - const CommandCenterPage({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => _CommandCenterPageState(); -} - -class _CommandCenterPageState extends State { - late final TextEditingController _commandController; - late final TextEditingController _cwdController; - late final TextEditingController _timeoutController; - late final TextEditingController _outputCapController; - late SandboxMode _sandboxMode; - late bool _allowNetwork; - bool _disableTimeout = false; - bool _disableOutputCap = true; - int _lastRows = 0; - int _lastCols = 0; - - @override - void initState() { - super.initState(); - final controller = widget.controller; - final settings = controller.settings; - _commandController = TextEditingController(); - _cwdController = TextEditingController( - text: controller.preferredCommandCwd, - ); - _timeoutController = TextEditingController(text: '60000'); - _outputCapController = TextEditingController(text: '32768'); - _sandboxMode = settings.sandboxMode; - _allowNetwork = settings.allowNetwork; - } - - @override - void dispose() { - _commandController.dispose(); - _cwdController.dispose(); - _timeoutController.dispose(); - _outputCapController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.controller, - builder: (BuildContext context, Widget? child) { - final activeSession = widget.controller.activeCommandSession; - final preferredCwd = widget.controller.preferredCommandCwd; - if (_commandController.text.isEmpty && - _cwdController.text.trim().isEmpty && - preferredCwd.isNotEmpty) { - _cwdController.text = preferredCwd; - } - final hasRunningCommand = widget.controller.commandSessions.any( - (session) => session.isRunning, - ); - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - leading: IconButton( - tooltip: 'Command settings', - onPressed: _openSettingsModal, - icon: const Icon(Icons.settings_outlined), - ), - title: const Text('Command Center'), - actions: [ - IconButton( - tooltip: 'Close', - onPressed: () => Navigator.of(context).maybePop(), - icon: const Icon(Icons.close), - ), - ], - ), - body: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final shellHeight = constraints.maxHeight * 0.6; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: shellHeight, - child: _CommandSessionView( - session: activeSession, - commandController: _commandController, - onSubmit: _submitShellInput, - onTerminate: activeSession == null - ? null - : () => widget.controller.terminateCommandSession( - activeSession.id, - ), - onResize: activeSession == null - ? null - : (int rows, int cols) { - if (_lastRows == rows && _lastCols == cols) { - return; - } - _lastRows = rows; - _lastCols = cols; - widget.controller.resizeCommandSession( - activeSession.id, - rows: rows, - cols: cols, - ); - }, - ), - ), - const SizedBox(height: 14), - Expanded( - child: _CommandHistoryPanel( - controller: widget.controller, - canRepeat: - _commandController.text.trim().isEmpty && - !hasRunningCommand, - onRepeat: _repeatCommandFromHistory, - ), - ), - ], - ); - }, - ), - ), - ), - ); - }, - ); - } - - Future _runCommand() async { - final timeoutMs = int.tryParse(_timeoutController.text.trim()) ?? 0; - final outputCap = int.tryParse(_outputCapController.text.trim()) ?? 0; - await widget.controller.startCommandExecution( - commandText: _commandController.text, - cwd: _cwdController.text, - sandboxMode: _sandboxMode, - allowNetwork: _allowNetwork, - mode: CommandSessionMode.interactive, - timeoutMs: timeoutMs, - disableTimeout: _disableTimeout, - outputBytesCap: outputCap, - disableOutputCap: _disableOutputCap, - rows: 24, - cols: 96, - ); - _commandController.clear(); - } - - Future _sendCommandInput(CommandSession session) async { - final text = _commandController.text; - _commandController.clear(); - await widget.controller.writeToCommandSession(session.id, '$text\n'); - } - - Future _submitShellInput() async { - final session = widget.controller.activeCommandSession; - final canSendToInteractive = - session != null && - session.isInteractive && - session.isRunning && - !session.stdinClosed; - if (canSendToInteractive) { - await _sendCommandInput(session); - return; - } - await _runCommand(); - } - - void _applyRecentCommand(RecentCommand recent) { - _commandController.text = recent.commandText; - _cwdController.text = recent.cwd; - _timeoutController.text = recent.timeoutMs.toString(); - _outputCapController.text = recent.outputBytesCap.toString(); - setState(() { - _sandboxMode = recent.sandboxMode; - _allowNetwork = recent.allowNetwork; - _disableTimeout = recent.disableTimeout; - _disableOutputCap = recent.disableOutputCap; - }); - } - - void _repeatCommandFromHistory(CommandSession session) { - if (_commandController.text.trim().isNotEmpty) { - return; - } - if (widget.controller.commandSessions.any((item) => item.isRunning)) { - return; - } - final command = session.commandDisplay.trim(); - if (command.isEmpty) { - return; - } - _commandController - ..text = command - ..selection = TextSelection.collapsed(offset: command.length); - } - - Future _openSettingsModal() async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: true, - builder: (BuildContext context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setModalState) { - return FractionallySizedBox( - heightFactor: 0.82, - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CommandForm( - cwdController: _cwdController, - timeoutController: _timeoutController, - outputCapController: _outputCapController, - sandboxMode: _sandboxMode, - allowNetwork: _allowNetwork, - disableTimeout: _disableTimeout, - disableOutputCap: _disableOutputCap, - onSandboxChanged: (SandboxMode value) { - setState(() => _sandboxMode = value); - setModalState(() {}); - }, - onAllowNetworkChanged: (bool value) { - setState(() => _allowNetwork = value); - setModalState(() {}); - }, - onDisableTimeoutChanged: (bool value) { - setState(() => _disableTimeout = value); - setModalState(() {}); - }, - onDisableOutputCapChanged: (bool value) { - setState(() => _disableOutputCap = value); - setModalState(() {}); - }, - ), - const SizedBox(height: 12), - _SavedCommandPanel( - controller: widget.controller, - onTapCommand: (RecentCommand recent) { - _applyRecentCommand(recent); - Navigator.of(context).pop(); - }, - ), - ], - ), - ), - ), - ); - }, - ); - }, - ); - } -} - -class _CommandForm extends StatelessWidget { - const _CommandForm({ - required this.cwdController, - required this.timeoutController, - required this.outputCapController, - required this.sandboxMode, - required this.allowNetwork, - required this.disableTimeout, - required this.disableOutputCap, - required this.onSandboxChanged, - required this.onAllowNetworkChanged, - required this.onDisableTimeoutChanged, - required this.onDisableOutputCapChanged, - }); - - final TextEditingController cwdController; - final TextEditingController timeoutController; - final TextEditingController outputCapController; - final SandboxMode sandboxMode; - final bool allowNetwork; - final bool disableTimeout; - final bool disableOutputCap; - final ValueChanged onSandboxChanged; - final ValueChanged onAllowNetworkChanged; - final ValueChanged onDisableTimeoutChanged; - final ValueChanged onDisableOutputCapChanged; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Setup', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.terminal_outlined), - title: const Text('Interactive shell'), - subtitle: const Text('Commands always run in interactive mode.'), - ), - const SizedBox(height: 12), - TextField( - controller: cwdController, - decoration: const InputDecoration(labelText: 'Working directory'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: sandboxMode, - decoration: const InputDecoration(labelText: 'Sandbox'), - items: SandboxMode.values - .map( - (SandboxMode item) => DropdownMenuItem( - value: item, - child: Text(item.name), - ), - ) - .toList(), - onChanged: (SandboxMode? value) { - if (value != null) { - onSandboxChanged(value); - } - }, - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Network'), - value: allowNetwork, - onChanged: onAllowNetworkChanged, - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Disable timeout'), - value: disableTimeout, - onChanged: onDisableTimeoutChanged, - ), - if (!disableTimeout) ...[ - TextField( - controller: timeoutController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Timeout ms'), - ), - const SizedBox(height: 8), - ], - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Disable output cap'), - value: disableOutputCap, - onChanged: onDisableOutputCapChanged, - ), - if (!disableOutputCap) ...[ - TextField( - controller: outputCapController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Output cap bytes'), - ), - const SizedBox(height: 8), - ], - ], - ), - ); - } -} - -class _CommandSessionCard extends StatelessWidget { - const _CommandSessionCard({ - required this.session, - required this.selected, - required this.onTap, - required this.canRepeat, - required this.onRepeat, - }); - - final CommandSession session; - final bool selected; - final VoidCallback onTap; - final bool canRepeat; - final VoidCallback onRepeat; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.primary.withValues(alpha: 0.08) - : theme.colorScheme.surface, - border: Border.all( - color: selected ? theme.colorScheme.primary : theme.dividerColor, - ), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconButton( - tooltip: 'Repeat', - onPressed: canRepeat ? onRepeat : null, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor(width: 28, height: 28), - splashRadius: 16, - icon: const Icon(Icons.replay, size: 16), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.commandDisplay, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 6), - Text( - session.statusLabel, - style: theme.textTheme.bodySmall, - ), - if (session.cwd.isNotEmpty) - Text( - session.cwd, - style: theme.textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _RecentCommandCard extends StatelessWidget { - const _RecentCommandCard({ - required this.command, - required this.onTap, - required this.onRemove, - }); - - final RecentCommand command; - final VoidCallback onTap; - final VoidCallback onRemove; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - command.commandText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 6), - Text( - command.sandboxMode.name, - style: theme.textTheme.bodySmall, - ), - if (command.cwd.isNotEmpty) - Text( - command.cwd, - style: theme.textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - IconButton( - tooltip: 'Remove saved command', - onPressed: onRemove, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor(width: 28, height: 28), - splashRadius: 16, - icon: const Icon(Icons.close, size: 16), - ), - ], - ), - ), - ); - } -} - -class _CommandSessionView extends StatefulWidget { - const _CommandSessionView({ - required this.session, - required this.commandController, - required this.onSubmit, - required this.onTerminate, - required this.onResize, - }); - - final CommandSession? session; - final TextEditingController commandController; - final Future Function() onSubmit; - final VoidCallback? onTerminate; - final void Function(int rows, int cols)? onResize; - - @override - State<_CommandSessionView> createState() => _CommandSessionViewState(); -} - -class _CommandSessionViewState extends State<_CommandSessionView> { - final ScrollController _verticalOutputController = ScrollController(); - final ScrollController _horizontalOutputController = ScrollController(); - - @override - void dispose() { - _verticalOutputController.dispose(); - _horizontalOutputController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final session = widget.session; - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final interactiveInput = - session != null && - session.isInteractive && - session.isRunning && - !session.stdinClosed; - return Container( - key: const ValueKey('command-shell-panel'), - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session?.commandDisplay ?? 'Shell', - key: const ValueKey('command-shell-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - session?.cwd.isNotEmpty == true - ? session!.cwd - : 'Terminal ready', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - if (widget.onTerminate != null) - IconButton( - tooltip: 'Terminate', - onPressed: session?.isRunning == true - ? widget.onTerminate - : null, - icon: const Icon(Icons.stop_circle_outlined), - ), - ], - ), - ), - Divider(height: 1, color: theme.dividerColor), - Expanded( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final rows = (constraints.maxHeight / 18).floor().clamp(10, 60); - final cols = (constraints.maxWidth / 8).floor().clamp(40, 160); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (session != null && - session.isInteractive && - session.usesTty && - session.isRunning && - widget.onResize != null) { - widget.onResize!(rows, cols); - } - }); - return Scrollbar( - controller: _verticalOutputController, - thumbVisibility: true, - child: SingleChildScrollView( - controller: _verticalOutputController, - primary: false, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: Scrollbar( - controller: _horizontalOutputController, - thumbVisibility: true, - notificationPredicate: (notification) => - notification.metrics.axis == Axis.horizontal, - child: SingleChildScrollView( - controller: _horizontalOutputController, - primary: false, - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: constraints.maxWidth - 24, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (session != null && - session.stdout.isNotEmpty) - _MonospaceOutputView( - text: session.stdout, - scrollable: false, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - height: 1.2, - color: scheme.onSurface, - ), - ), - if (session == null) - Text( - '\$ Enter a command below to start a shell session.', - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: scheme.onSurfaceVariant, - ), - ), - if (session != null && - session.stdout.isEmpty && - session.stderr.isEmpty) - Text( - session.isRunning - ? 'Waiting for output...' - : 'Command produced no output.', - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: scheme.onSurfaceVariant, - ), - ), - if (session != null && - session.outputCapReached) ...[ - const SizedBox(height: 10), - Text( - 'Output cap reached', - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: scheme.secondary, - ), - ), - ], - if (session != null && - session.stderr.isNotEmpty) ...[ - const SizedBox(height: 10), - _MonospaceOutputView( - text: session.stderr, - scrollable: false, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: scheme.error, - height: 1.2, - ), - ), - ], - ], - ), - ), - ), - ), - ), - ); - }, - ), - ), - Divider(height: 1, color: theme.dividerColor), - Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '\$', - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - color: scheme.primary, - fontWeight: FontWeight.w700, - height: 1.2, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - key: const ValueKey('command-shell-input'), - controller: widget.commandController, - onSubmitted: (_) => widget.onSubmit(), - textInputAction: TextInputAction.send, - maxLines: 1, - cursorColor: scheme.primary, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - hintText: interactiveInput - ? 'stdin to active process' - : 'type a command and press Enter', - hintStyle: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - color: scheme.onSurfaceVariant, - height: 1.2, - ), - ), - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - color: scheme.onSurface, - height: 1.2, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _CommandHistoryPanel extends StatelessWidget { - const _CommandHistoryPanel({ - required this.controller, - required this.canRepeat, - required this.onRepeat, - }); - - final AppController controller; - final bool canRepeat; - final ValueChanged onRepeat; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final hasFinishedSessions = controller.commandSessions.any( - (session) => !session.isRunning, - ); - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Shell history', style: theme.textTheme.titleMedium), - const Spacer(), - IconButton( - tooltip: 'Clear finished runs', - onPressed: hasFinishedSessions - ? controller.clearFinishedCommandSessions - : null, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - icon: const Icon(Icons.delete_outline), - ), - if (controller.commandSessions.isNotEmpty) - Text( - '${controller.commandSessions.length}', - style: theme.textTheme.bodySmall, - ), - ], - ), - const SizedBox(height: 8), - Expanded( - child: controller.commandSessions.isEmpty - ? Center( - child: Text( - 'No shell commands yet.', - style: theme.textTheme.bodySmall, - ), - ) - : ListView.separated( - itemCount: controller.commandSessions.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (BuildContext context, int index) { - final session = controller.commandSessions[index]; - final selected = - controller.activeCommandSession?.id == session.id; - return _CommandSessionCard( - session: session, - selected: selected, - canRepeat: canRepeat, - onRepeat: () => onRepeat(session), - onTap: () => controller.selectCommandSession(session.id), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _SavedCommandPanel extends StatelessWidget { - const _SavedCommandPanel({ - required this.controller, - required this.onTapCommand, - }); - - final AppController controller; - final ValueChanged onTapCommand; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Saved', style: theme.textTheme.titleMedium), - const Spacer(), - if (controller.recentCommands.isNotEmpty) - Text( - '${controller.recentCommands.length}', - style: theme.textTheme.bodySmall, - ), - ], - ), - const SizedBox(height: 8), - if (controller.recentCommands.isEmpty) - Text( - 'Saved commands appear here after you run them.', - style: theme.textTheme.bodySmall, - ) - else - ...controller.recentCommands.map((recent) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _RecentCommandCard( - command: recent, - onTap: () => onTapCommand(recent), - onRemove: () => controller.removeRecentCommand(recent), - ), - ); - }), - ], - ), - ); - } -} - -class ThreadHistorySheet extends StatelessWidget { - const ThreadHistorySheet({ - super.key, - required this.controller, - required this.onCreateThread, - }); - - final AppController controller; - final Future Function() onCreateThread; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return AnimatedBuilder( - animation: controller, - builder: (BuildContext context, Widget? child) { - return Container( - margin: const EdgeInsets.only(right: 24), - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - borderRadius: const BorderRadius.horizontal( - right: Radius.circular(22), - ), - ), - child: SafeArea( - child: Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: - MediaQuery.paddingOf(context).bottom + - MediaQuery.viewInsetsOf(context).bottom + - 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Threads', - style: theme.textTheme.titleLarge, - ), - ), - IconButton( - tooltip: 'New thread', - onPressed: onCreateThread, - icon: const Icon(Icons.add), - ), - const SizedBox(width: 8), - IconButton( - tooltip: 'Refresh', - onPressed: controller.isLoadingHistory - ? null - : () => controller.loadThreadHistory(reset: true), - icon: const Icon(Icons.refresh), - ), - ], - ), - if (controller.threadHistoryError != null) ...[ - const SizedBox(height: 8), - Text( - controller.threadHistoryError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - const SizedBox(height: 12), - SizedBox( - height: MediaQuery.sizeOf(context).height * 0.7, - child: - controller.threadHistory.isEmpty && - controller.isLoadingHistory - ? const Center(child: CircularProgressIndicator()) - : controller.threadHistory.isEmpty - ? Center( - child: Text( - 'No saved threads were returned by the server.', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ) - : ListView.separated( - itemCount: - controller.threadHistory.length + - (controller.hasMoreThreadHistory ? 1 : 0), - separatorBuilder: (_, _) => - const SizedBox(height: 8), - itemBuilder: (BuildContext context, int index) { - if (index >= controller.threadHistory.length) { - return OutlinedButton( - onPressed: controller.isLoadingHistory - ? null - : controller.loadThreadHistory, - child: Text( - controller.isLoadingHistory - ? 'Loading' - : 'Load more', - ), - ); - } - - final thread = controller.threadHistory[index]; - return _ThreadTile( - controller: controller, - thread: thread, - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - ); - } -} - -class FileBrowserSheet extends StatefulWidget { - const FileBrowserSheet({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => _FileBrowserSheetState(); -} - -class _FileBrowserSheetState extends State { - late final TextEditingController _pathController; - - @override - void initState() { - super.initState(); - _pathController = TextEditingController( - text: widget.controller.fileBrowserPath, - ); - } - - @override - void dispose() { - _pathController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return AnimatedBuilder( - animation: widget.controller, - builder: (BuildContext context, Widget? child) { - final controller = widget.controller; - if (_pathController.text != controller.fileBrowserPath) { - _pathController.value = _pathController.value.copyWith( - text: controller.fileBrowserPath, - selection: TextSelection.collapsed( - offset: controller.fileBrowserPath.length, - ), - ); - } - return Scaffold( - backgroundColor: Colors.transparent, - body: Container( - margin: const EdgeInsets.only(right: 24), - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor, - borderRadius: const BorderRadius.horizontal( - right: Radius.circular(22), - ), - ), - child: SafeArea( - child: Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.viewInsetsOf(context).bottom + 20, - ), - child: SizedBox( - height: MediaQuery.sizeOf(context).height, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Files', - style: theme.textTheme.titleLarge, - ), - ), - IconButton( - tooltip: 'Close', - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - IconButton( - tooltip: 'Up', - onPressed: controller.fileBrowserPath == '/' - ? null - : controller.navigateToParentDirectory, - icon: const Icon(Icons.arrow_upward), - ), - Expanded( - child: TextField( - controller: _pathController, - decoration: const InputDecoration( - labelText: 'Absolute path', - ), - onSubmitted: controller.loadDirectory, - ), - ), - const SizedBox(width: 8), - OutlinedButton( - onPressed: controller.isLoadingFiles - ? null - : () => controller.loadDirectory( - _pathController.text, - ), - child: const Text('Open'), - ), - ], - ), - if (controller.fileBrowserError != null) ...[ - const SizedBox(height: 8), - Text( - controller.fileBrowserError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - const SizedBox(height: 12), - Expanded(child: _buildFileList(theme, controller)), - ], - ), - ), - ), - ), - ), - ); - }, - ); - } - - Future _downloadFile(BuildContext context, String filePath) async { - try { - await widget.controller.saveFileToDevice(filePath); - } catch (error) { - // Download errors are shown in the download center. - } - } - - Future _cancelDownload(String filePath) async { - await widget.controller.cancelFileDownload(filePath); - } - - Future _openPreviewForFile(String filePath, {int? line}) async { - await widget.controller.openFile(filePath, highlightedLine: line); - if (!mounted) { - return; - } - await Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) { - return FilePreviewPage( - controller: widget.controller, - onDownload: _downloadFile, - onCancelDownload: _cancelDownload, - ); - }, - ), - ); - } - - Widget _buildFileList(ThemeData theme, AppController controller) { - return Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(12), - ), - child: controller.isLoadingFiles && controller.fileBrowserEntries.isEmpty - ? const Center(child: CircularProgressIndicator()) - : ListView.separated( - padding: const EdgeInsets.all(12), - itemCount: controller.fileBrowserEntries.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (BuildContext context, int index) { - final entry = controller.fileBrowserEntries[index]; - final fullPath = controller.joinFileBrowserPath(entry.fileName); - return _FileEntryTile( - controller: controller, - entry: entry, - isDownloading: controller.isFileDownloading(fullPath), - downloadStatus: controller.fileDownloadStatus(fullPath), - onOpenFile: entry.isFile - ? () => _openPreviewForFile(fullPath) - : null, - onDownload: entry.isFile - ? () => _downloadFile(context, fullPath) - : null, - onCancelDownload: entry.isFile - ? () => _cancelDownload(fullPath) - : null, - ); - }, - ), - ); - } -} - -class FilePreviewPage extends StatefulWidget { - const FilePreviewPage({ - super.key, - required this.controller, - required this.onDownload, - required this.onCancelDownload, - }); - - final AppController controller; - final Future Function(BuildContext context, String filePath) onDownload; - final Future Function(String filePath) onCancelDownload; - - @override - State createState() => _FilePreviewPageState(); -} - -class _FilePreviewPageState extends State { - late final TextEditingController _editorController = TextEditingController(); - String? _editingPath; - bool _isEditing = false; - - @override - void dispose() { - _editorController.dispose(); - super.dispose(); - } - - void _syncEditorFromController() { - final controller = widget.controller; - final path = controller.selectedFilePath; - if (!_isEditing && - controller.selectedFileIsHumanReadable && - path != null && - path.isNotEmpty && - _editingPath != path) { - _editingPath = path; - _editorController.text = controller.selectedFileContent ?? ''; - } - if (!_isEditing && !controller.selectedFileIsHumanReadable) { - _editingPath = null; - _editorController.clear(); - } - } - - Future _saveFile(BuildContext context) async { - try { - await widget.controller.saveOpenedFileContent(_editorController.text); - if (!mounted) { - return; - } - setState(() { - _isEditing = false; - }); - } catch (_) { - if (!context.mounted) { - return; - } - final message = widget.controller.filePreviewSaveError?.trim(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - message == null || message.isEmpty - ? 'Unable to save the file.' - : message, - ), - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.controller, - builder: (BuildContext context, Widget? child) { - _syncEditorFromController(); - final controller = widget.controller; - final filePath = controller.selectedFilePath; - final canEdit = - controller.selectedFileIsHumanReadable && - filePath != null && - filePath.isNotEmpty; - return Scaffold( - appBar: AppBar( - title: Text( - filePath == null || filePath.isEmpty ? 'File preview' : filePath, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - actions: [ - if (canEdit) - TextButton( - onPressed: controller.isSavingFilePreview - ? null - : () { - if (_isEditing) { - _saveFile(context); - } else { - setState(() { - _isEditing = true; - _editingPath = filePath; - _editorController.text = - controller.selectedFileContent ?? ''; - }); - } - }, - child: Text(_isEditing ? 'Save' : 'Edit'), - ), - if (canEdit && _isEditing) - TextButton( - onPressed: controller.isSavingFilePreview - ? null - : () { - setState(() { - _isEditing = false; - _editorController.text = - controller.selectedFileContent ?? ''; - }); - }, - child: const Text('Cancel'), - ), - if (filePath != null && filePath.isNotEmpty) - Padding( - padding: const EdgeInsets.only(right: 8), - child: controller.isFileDownloading(filePath) - ? ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: _DownloadProgressPanel( - status: controller.fileDownloadStatus(filePath), - onCancel: () => widget.onCancelDownload(filePath), - ), - ) - : OutlinedButton.icon( - onPressed: controller.selectedFileBytes == null - ? null - : () => widget.onDownload(context, filePath), - icon: const Icon(Icons.download_outlined, size: 18), - label: const Text('Download'), - ), - ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 16, 16, 16), - child: _FilePreviewBody( - controller: controller, - isEditing: _isEditing, - editorController: _editorController, - ), - ), - ), - ); - }, - ); - } -} - -class _FilePreviewBody extends StatefulWidget { - const _FilePreviewBody({ - required this.controller, - required this.isEditing, - required this.editorController, - }); - - final AppController controller; - final bool isEditing; - final TextEditingController editorController; - - @override - State<_FilePreviewBody> createState() => _FilePreviewBodyState(); -} - -class _FilePreviewBodyState extends State<_FilePreviewBody> { - static const double _lineHeight = 22; - final ScrollController _verticalController = ScrollController(); - final ScrollController _horizontalController = ScrollController(); - int? _lastScrolledLine; - - @override - void dispose() { - _verticalController.dispose(); - _horizontalController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final controller = widget.controller; - final theme = Theme.of(context); - if (controller.isLoadingFilePreview) { - return const Center(child: CircularProgressIndicator()); - } - if (controller.selectedFilePath == null) { - return Center( - child: Text( - 'Select a file to preview it.', - style: theme.textTheme.bodyMedium, - ), - ); - } - if (controller.selectedFileIsHumanReadable) { - if (widget.isEditing) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.filePreviewSaveError != null) ...[ - Padding( - padding: const EdgeInsets.only(left: 16, bottom: 8), - child: Text( - controller.filePreviewSaveError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ), - ], - Expanded( - child: TextField( - controller: widget.editorController, - expands: true, - maxLines: null, - minLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.top, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - height: 1.35, - color: theme.colorScheme.onSurface, - ), - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB(16, 0, 0, 0), - hintText: 'Edit file contents', - hintStyle: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - ), - ], - ); - } - final lines = (controller.selectedFileContent ?? '').split('\n'); - final highlightedLine = controller.selectedFileHighlightedLine; - if (highlightedLine != null && - highlightedLine > 0 && - highlightedLine <= lines.length && - _lastScrolledLine != highlightedLine) { - _lastScrolledLine = highlightedLine; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_verticalController.hasClients) { - return; - } - final targetOffset = ((highlightedLine - 1) * _lineHeight) - 80; - _verticalController.animateTo( - targetOffset.clamp(0, _verticalController.position.maxScrollExtent), - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - ); - }); - } - return Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (notification) => - notification.metrics.axis == Axis.horizontal, - child: SingleChildScrollView( - controller: _horizontalController, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: 720, - child: Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: ListView.builder( - controller: _verticalController, - itemCount: lines.length, - itemBuilder: (BuildContext context, int index) { - final lineNumber = index + 1; - final isHighlighted = highlightedLine == lineNumber; - return Container( - key: isHighlighted - ? const ValueKey('highlighted-file-line') - : null, - height: _lineHeight, - color: isHighlighted - ? theme.colorScheme.primary.withValues(alpha: 0.12) - : null, - padding: const EdgeInsets.only(right: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 56, - child: Text( - '$lineNumber', - textAlign: TextAlign.right, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: isHighlighted - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: SelectableText( - lines[index], - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - height: 1.3, - color: isHighlighted - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ); - }, - ), - ), - ), - ), - ); - } - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.insert_drive_file_outlined, - size: 36, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'This file is not previewed as text.', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Use Download to save it locally and open it with an appropriate app.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - if (controller.selectedFileBytes != null) ...[ - const SizedBox(height: 12), - Text( - '${controller.selectedFileBytes!.length} bytes', - style: theme.textTheme.bodySmall, - ), - ], - ], - ), - ), - ); - } -} - -class _FileEntryTile extends StatelessWidget { - const _FileEntryTile({ - required this.controller, - required this.entry, - required this.isDownloading, - required this.downloadStatus, - this.onOpenFile, - this.onDownload, - this.onCancelDownload, - }); - - final AppController controller; - final FileSystemEntry entry; - final bool isDownloading; - final FileDownloadStatus? downloadStatus; - final VoidCallback? onOpenFile; - final VoidCallback? onDownload; - final VoidCallback? onCancelDownload; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final fullPath = controller.joinFileBrowserPath(entry.fileName); - final selected = controller.selectedFilePath == fullPath; - return InkWell( - onTap: () { - if (entry.isDirectory) { - controller.loadDirectory(fullPath); - } else if (entry.isFile) { - onOpenFile?.call(); - } - }, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - entry.isDirectory - ? Icons.folder_outlined - : Icons.description_outlined, - size: 18, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - entry.fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, - ), - ), - if (entry.isFile && onDownload != null) ...[ - const SizedBox(width: 8), - isDownloading - ? IconButton( - tooltip: 'Cancel download', - onPressed: onCancelDownload, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close, size: 18), - ) - : IconButton( - tooltip: 'Download', - onPressed: onDownload, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.download_outlined, size: 18), - ), - ], - ], - ), - if (isDownloading && downloadStatus != null) ...[ - const SizedBox(height: 8), - _DownloadProgressDetails(status: downloadStatus!), - ], - ], - ), - ), - ); - } -} - -class _DownloadProgressPanel extends StatelessWidget { - const _DownloadProgressPanel({required this.status, required this.onCancel}); - - final FileDownloadStatus? status; - final VoidCallback onCancel; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Expanded(child: _DownloadProgressDetails(status: status)), - const SizedBox(width: 10), - IconButton( - tooltip: 'Cancel download', - onPressed: onCancel, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close, size: 18), - ), - ], - ), - ); - } -} - -class _DownloadProgressDetails extends StatelessWidget { - const _DownloadProgressDetails({required this.status}); - - final FileDownloadStatus? status; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final progress = (status?.progress ?? 0).clamp(0.0, 1.0); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - LinearProgressIndicator(value: progress), - const SizedBox(height: 6), - Text(_formatTransferSize(status), style: theme.textTheme.bodySmall), - const SizedBox(height: 2), - Text( - _formatEta(status), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ); - } -} - -String _formatTransferSize(FileDownloadStatus? status) { - final received = _formatMegabytes(status?.receivedBytes ?? 0); - final totalBytes = status?.totalBytes; - final total = totalBytes == null ? '--' : _formatMegabytes(totalBytes); - return '$received MB / $total MB'; -} - -String _formatEta(FileDownloadStatus? status) { - final eta = status?.eta; - if (eta == null) { - return 'Estimating time remaining...'; - } - if (eta == Duration.zero) { - return 'Almost done'; - } - final seconds = eta.inSeconds; - if (seconds < 60) { - return '${seconds}s remaining'; - } - final minutes = eta.inMinutes; - final remainingSeconds = seconds % 60; - if (minutes < 60) { - return '${minutes}m ${remainingSeconds}s remaining'; - } - final hours = eta.inHours; - final remainingMinutes = minutes % 60; - return '${hours}h ${remainingMinutes}m remaining'; -} - -String _formatMegabytes(int bytes) { - final megabytes = bytes / (1024 * 1024); - return megabytes.toStringAsFixed(megabytes >= 10 ? 0 : 1); -} - -class _ThreadTile extends StatelessWidget { - const _ThreadTile({required this.controller, required this.thread}); - - final AppController controller; - final ThreadSummary thread; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isOpening = controller.openingThreadId == thread.id; - final isFavorite = controller.isThreadFavorite(thread.id); - final hasActiveTurn = controller.threadHasActiveTurn(thread.id); - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Row( - children: [ - if (hasActiveTurn) - Container( - key: ValueKey('thread-active-turn-${thread.id}'), - width: 10, - height: 10, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - color: theme.colorScheme.primary, - shape: BoxShape.circle, - ), - ), - Expanded( - child: Text( - thread.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, - ), - ), - ], - ), - ), - IconButton( - tooltip: isFavorite ? 'Unfavorite thread' : 'Favorite thread', - onPressed: () => controller.toggleFavoriteThread(thread.id), - icon: Icon( - isFavorite ? Icons.star : Icons.star_border, - color: isFavorite ? theme.colorScheme.primary : null, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - thread.preview.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (thread.updatedAt != null) - _ThreadMeta( - label: 'Updated', - value: _formatDate(thread.updatedAt!), - ), - if (thread.cwd.isNotEmpty) - _ThreadMeta(label: 'Cwd', value: thread.cwd), - if (thread.agentNickname != null && - thread.agentNickname!.isNotEmpty) - _ThreadMeta(label: 'Agent', value: thread.agentNickname!), - ], - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: isOpening - ? null - : () async { - await controller.resumeThreadFromHistory(thread.id); - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - child: Text(isOpening ? 'Opening' : 'Open thread'), - ), - ), - ], - ), - ); - } - - String _formatDate(DateTime value) { - final local = value; - return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}'; - } - - String two(int n) { - return n.toString().padLeft(2, '0'); - } -} - -class _ThreadMeta extends StatelessWidget { - const _ThreadMeta({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '$label: $value', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - ); - } -} +export 'features/workspace/presentation/home_page.dart'; +export 'features/downloads/presentation/download_center_page.dart'; +export 'features/automations/presentation/automation_pages.dart'; +export 'features/settings/presentation/settings_page.dart'; +export 'features/diagnostics/presentation/event_log_sheet.dart'; +export 'features/commands/presentation/command_center_page.dart'; +export 'features/threads/presentation/thread_history_sheet.dart'; +export 'features/files/presentation/file_pages.dart'; diff --git a/lib/src/models.dart b/lib/src/models.dart index f940435..3138cb7 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -1,720 +1,4 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; - -enum ThemePreference { system, light, dark } - -enum SandboxMode { workspaceWrite, readOnly, dangerFullAccess } - -enum ConnectionStatus { disconnected, connecting, initializing, ready, error } - -enum ConnectionMode { direct, relay } - -enum EntryKind { user, agent, reasoning, command, fileChange, tool, system } - -const Set validApprovalPolicies = { - 'untrusted', - 'on-request', - 'on-failure', - 'never', -}; - -String normalizeApprovalPolicy(String? value) { - final raw = (value ?? '').trim(); - if (raw == 'unlessTrusted') { - return 'untrusted'; - } - if (validApprovalPolicies.contains(raw)) { - return raw; - } - return 'untrusted'; -} - -class AppSettings { - const AppSettings({ - required this.connectionMode, - required this.serverUrl, - required this.websocketBearerToken, - required this.relayUrl, - required this.relayDeviceId, - required this.relayBridgeLabel, - required this.relayBridgeSigningPublicKey, - required this.relayClientPrivateKey, - required this.relayClientPublicKey, - required this.model, - required this.reasoningEffort, - required this.planMode, - required this.approvalPolicy, - required this.sandboxMode, - required this.allowNetwork, - required this.themePreference, - required this.resumeThreadId, - required this.favoriteThreadIds, - required this.threadDownloadDirectories, - required this.automationSnapshots, - required this.automations, - }); - - factory AppSettings.defaults() { - return const AppSettings( - connectionMode: ConnectionMode.direct, - serverUrl: 'ws://127.0.0.1:8080', - websocketBearerToken: '', - relayUrl: '', - relayDeviceId: '', - relayBridgeLabel: '', - relayBridgeSigningPublicKey: '', - relayClientPrivateKey: '', - relayClientPublicKey: '', - model: '', - reasoningEffort: 'medium', - planMode: false, - approvalPolicy: 'untrusted', - sandboxMode: SandboxMode.workspaceWrite, - allowNetwork: false, - themePreference: ThemePreference.system, - resumeThreadId: '', - favoriteThreadIds: [], - threadDownloadDirectories: {}, - automationSnapshots: >{}, - automations: [], - ); - } - - final ConnectionMode connectionMode; - final String serverUrl; - final String websocketBearerToken; - final String relayUrl; - final String relayDeviceId; - final String relayBridgeLabel; - final String relayBridgeSigningPublicKey; - final String relayClientPrivateKey; - final String relayClientPublicKey; - final String model; - final String reasoningEffort; - final bool planMode; - final String approvalPolicy; - final SandboxMode sandboxMode; - final bool allowNetwork; - final ThemePreference themePreference; - final String resumeThreadId; - final List favoriteThreadIds; - final Map threadDownloadDirectories; - final Map> automationSnapshots; - final List automations; - - ThemeMode get materialThemeMode { - return switch (themePreference) { - ThemePreference.system => ThemeMode.system, - ThemePreference.light => ThemeMode.light, - ThemePreference.dark => ThemeMode.dark, - }; - } - - String get activeConnectionLabel { - if (connectionMode == ConnectionMode.relay) { - final relay = relayUrl.trim(); - if (relay.isNotEmpty) { - return relay; - } - } - return serverUrl.trim(); - } - - AppSettings copyWith({ - ConnectionMode? connectionMode, - String? serverUrl, - String? websocketBearerToken, - String? relayUrl, - String? relayDeviceId, - String? relayBridgeLabel, - String? relayBridgeSigningPublicKey, - String? relayClientPrivateKey, - String? relayClientPublicKey, - String? model, - String? reasoningEffort, - bool? planMode, - String? approvalPolicy, - SandboxMode? sandboxMode, - bool? allowNetwork, - ThemePreference? themePreference, - String? resumeThreadId, - List? favoriteThreadIds, - Map? threadDownloadDirectories, - Map>? automationSnapshots, - List? automations, - }) { - return AppSettings( - connectionMode: connectionMode ?? this.connectionMode, - serverUrl: serverUrl ?? this.serverUrl, - websocketBearerToken: websocketBearerToken ?? this.websocketBearerToken, - relayUrl: relayUrl ?? this.relayUrl, - relayDeviceId: relayDeviceId ?? this.relayDeviceId, - relayBridgeLabel: relayBridgeLabel ?? this.relayBridgeLabel, - relayBridgeSigningPublicKey: - relayBridgeSigningPublicKey ?? this.relayBridgeSigningPublicKey, - relayClientPrivateKey: - relayClientPrivateKey ?? this.relayClientPrivateKey, - relayClientPublicKey: relayClientPublicKey ?? this.relayClientPublicKey, - model: model ?? this.model, - reasoningEffort: reasoningEffort ?? this.reasoningEffort, - planMode: planMode ?? this.planMode, - approvalPolicy: normalizeApprovalPolicy( - approvalPolicy ?? this.approvalPolicy, - ), - sandboxMode: sandboxMode ?? this.sandboxMode, - allowNetwork: allowNetwork ?? this.allowNetwork, - themePreference: themePreference ?? this.themePreference, - resumeThreadId: resumeThreadId ?? this.resumeThreadId, - favoriteThreadIds: favoriteThreadIds ?? this.favoriteThreadIds, - threadDownloadDirectories: - threadDownloadDirectories ?? this.threadDownloadDirectories, - automationSnapshots: automationSnapshots ?? this.automationSnapshots, - automations: automations ?? this.automations, - ); - } -} - -class ActivityEntry { - ActivityEntry({ - required this.key, - required this.kind, - required this.title, - this.body = '', - this.secondary = '', - this.status = '', - this.isStreaming = false, - this.isLocalPending = false, - DateTime? timestamp, - }) : timestamp = timestamp ?? DateTime.now(); - - final String key; - final EntryKind kind; - final DateTime timestamp; - String title; - String body; - String secondary; - String status; - bool isStreaming; - bool isLocalPending; -} - -class PendingApproval { - PendingApproval({ - required this.requestId, - required this.method, - required this.itemId, - required this.title, - required this.detail, - required this.availableDecisions, - }); - - final int requestId; - final String method; - final String itemId; - final String title; - final String detail; - final List availableDecisions; -} - -class EventLogEntry { - EventLogEntry(this.method, this.summary) : timestamp = DateTime.now(); - - final String method; - final String summary; - final DateTime timestamp; -} - -class ThreadSummary { - const ThreadSummary({ - required this.id, - required this.preview, - required this.cwd, - required this.source, - required this.modelProvider, - required this.createdAt, - required this.updatedAt, - required this.status, - this.name, - this.agentNickname, - this.agentRole, - }); - - final String id; - final String preview; - final String cwd; - final String source; - final String modelProvider; - final DateTime? createdAt; - final DateTime? updatedAt; - final String status; - final String? name; - final String? agentNickname; - final String? agentRole; - - String get title { - final named = name?.trim() ?? ''; - if (named.isNotEmpty) { - return named; - } - final trimmed = preview.trim(); - if (trimmed.isNotEmpty) { - return trimmed; - } - return 'Untitled thread'; - } -} - -class FileSystemEntry { - const FileSystemEntry({ - required this.fileName, - required this.isDirectory, - required this.isFile, - }); - - final String fileName; - final bool isDirectory; - final bool isFile; -} - -class ModelOption { - const ModelOption({ - required this.id, - required this.model, - required this.displayName, - required this.description, - required this.isDefault, - required this.hidden, - }); - - final String id; - final String model; - final String displayName; - final String description; - final bool isDefault; - final bool hidden; -} - -enum PendingPromptMode { queued, steer } - -class PendingPrompt { - const PendingPrompt({ - required this.id, - required this.text, - required this.mode, - this.attachments = const [], - }); - - final String id; - final String text; - final PendingPromptMode mode; - final List attachments; - - PendingPrompt copyWith({ - String? id, - String? text, - PendingPromptMode? mode, - List? attachments, - }) { - return PendingPrompt( - id: id ?? this.id, - text: text ?? this.text, - mode: mode ?? this.mode, - attachments: attachments ?? this.attachments, - ); - } -} - -enum ComposerAttachmentKind { textFile, image } - -class ComposerAttachment { - const ComposerAttachment({ - required this.id, - required this.fileName, - required this.kind, - required this.bytes, - this.mimeType, - this.textContent, - }); - - final String id; - final String fileName; - final ComposerAttachmentKind kind; - final Uint8List bytes; - final String? mimeType; - final String? textContent; - - bool get isImage => kind == ComposerAttachmentKind.image; - bool get isTextFile => kind == ComposerAttachmentKind.textFile; - - String? get dataUrl { - final type = mimeType; - if (type == null || type.isEmpty) { - return null; - } - return 'data:$type;base64,${base64Encode(bytes)}'; - } -} - -bool isLikelyHumanReadableFile(String path, Uint8List bytes) { - const textExtensions = { - 'txt', - 'md', - 'markdown', - 'json', - 'yaml', - 'yml', - 'toml', - 'xml', - 'html', - 'css', - 'js', - 'ts', - 'tsx', - 'jsx', - 'dart', - 'kt', - 'java', - 'swift', - 'm', - 'mm', - 'c', - 'cc', - 'cpp', - 'h', - 'hpp', - 'rs', - 'go', - 'py', - 'rb', - 'php', - 'sh', - 'zsh', - 'bash', - 'fish', - 'sql', - 'csv', - 'log', - 'ini', - 'cfg', - 'conf', - 'env', - 'gitignore', - 'pubspec', - 'lock', - }; - - final segments = path.split('/'); - final fileName = segments.isEmpty ? path : segments.last; - final extension = fileName.contains('.') - ? fileName.split('.').last.toLowerCase() - : fileName.toLowerCase(); - if (textExtensions.contains(extension)) { - return true; - } - - if (bytes.isEmpty) { - return true; - } - - var suspicious = 0; - for (final byte in bytes.take(512)) { - if (byte == 0) { - return false; - } - if (byte < 9 || (byte > 13 && byte < 32)) { - suspicious += 1; - } - } - return suspicious < 12; -} - -enum CommandSessionMode { buffered, interactive } - -class RecentCommand { - const RecentCommand({ - required this.commandText, - required this.cwd, - required this.mode, - required this.sandboxMode, - required this.allowNetwork, - required this.disableTimeout, - required this.timeoutMs, - required this.disableOutputCap, - required this.outputBytesCap, - }); - - final String commandText; - final String cwd; - final CommandSessionMode mode; - final SandboxMode sandboxMode; - final bool allowNetwork; - final bool disableTimeout; - final int timeoutMs; - final bool disableOutputCap; - final int outputBytesCap; -} - -class CommandSession { - CommandSession({ - required this.id, - required this.processId, - required this.commandDisplay, - required this.cwd, - required this.mode, - required this.usesTty, - required this.startedAt, - this.exitCode, - this.stdout = '', - this.stderr = '', - this.status = 'running', - this.stdinClosed = false, - this.outputCapReached = false, - }); - - final String id; - final String processId; - final String commandDisplay; - final String cwd; - final CommandSessionMode mode; - final bool usesTty; - final DateTime startedAt; - int? exitCode; - String stdout; - String stderr; - String status; - bool stdinClosed; - bool outputCapReached; - - bool get isRunning => status == 'running'; - bool get isInteractive => mode == CommandSessionMode.interactive; - - String get statusLabel { - if (isRunning) { - return 'running'; - } - if (exitCode != null) { - return 'exit $exitCode'; - } - return status; - } -} - -enum AutomationNodeKind { - watchFileChanged, - watchDirectoryChanged, - turnCompleted, - didPathChangeSinceLastRun, - ifElse, - quit, - downloadChangedFile, - installDownloadedApk, - sendMessageToCurrentThread, - runCommand, -} - -enum AutomationBranchOutcome { continueFlow, quitFlow } - -extension AutomationNodeKindUi on AutomationNodeKind { - bool get isTrigger { - return this == AutomationNodeKind.watchFileChanged || - this == AutomationNodeKind.watchDirectoryChanged || - this == AutomationNodeKind.turnCompleted; - } - - String get title { - return switch (this) { - AutomationNodeKind.watchFileChanged => 'Watch file changes', - AutomationNodeKind.watchDirectoryChanged => 'Watch folder changes', - AutomationNodeKind.turnCompleted => 'Turn completed', - AutomationNodeKind.didPathChangeSinceLastRun => - 'Did file or folder change', - AutomationNodeKind.ifElse => 'If / else', - AutomationNodeKind.quit => 'Quit', - AutomationNodeKind.downloadChangedFile => 'Download changed file', - AutomationNodeKind.installDownloadedApk => 'Install downloaded APK', - AutomationNodeKind.sendMessageToCurrentThread => - 'Send message to current thread', - AutomationNodeKind.runCommand => 'Run command', - }; - } - - IconData get icon { - return switch (this) { - AutomationNodeKind.watchFileChanged => Icons.description_outlined, - AutomationNodeKind.watchDirectoryChanged => Icons.folder_outlined, - AutomationNodeKind.turnCompleted => Icons.task_alt_outlined, - AutomationNodeKind.didPathChangeSinceLastRun => - Icons.rule_folder_outlined, - AutomationNodeKind.ifElse => Icons.call_split_outlined, - AutomationNodeKind.quit => Icons.stop_circle_outlined, - AutomationNodeKind.downloadChangedFile => Icons.download_outlined, - AutomationNodeKind.installDownloadedApk => Icons.android_outlined, - AutomationNodeKind.sendMessageToCurrentThread => - Icons.mark_chat_unread_outlined, - AutomationNodeKind.runCommand => Icons.terminal_outlined, - }; - } -} - -class AutomationNode { - const AutomationNode({ - required this.id, - required this.kind, - this.path = '', - this.commandText = '', - this.cwd = '', - this.directory = '', - this.conditionToken = '', - this.whenTrue = AutomationBranchOutcome.continueFlow, - this.whenFalse = AutomationBranchOutcome.quitFlow, - }); - - final String id; - final AutomationNodeKind kind; - final String path; - final String commandText; - final String cwd; - final String directory; - final String conditionToken; - final AutomationBranchOutcome whenTrue; - final AutomationBranchOutcome whenFalse; - - AutomationNode copyWith({ - String? id, - AutomationNodeKind? kind, - String? path, - String? commandText, - String? cwd, - String? directory, - String? conditionToken, - AutomationBranchOutcome? whenTrue, - AutomationBranchOutcome? whenFalse, - }) { - return AutomationNode( - id: id ?? this.id, - kind: kind ?? this.kind, - path: path ?? this.path, - commandText: commandText ?? this.commandText, - cwd: cwd ?? this.cwd, - directory: directory ?? this.directory, - conditionToken: conditionToken ?? this.conditionToken, - whenTrue: whenTrue ?? this.whenTrue, - whenFalse: whenFalse ?? this.whenFalse, - ); - } - - Map toJson() { - return { - 'id': id, - 'kind': kind.name, - 'path': path, - 'commandText': commandText, - 'cwd': cwd, - 'directory': directory, - 'conditionToken': conditionToken, - 'whenTrue': whenTrue.name, - 'whenFalse': whenFalse.name, - }; - } - - factory AutomationNode.fromJson(Map json) { - final kindName = json['kind']?.toString() ?? ''; - final kind = AutomationNodeKind.values.firstWhere( - (value) => value.name == kindName, - orElse: () => AutomationNodeKind.runCommand, - ); - return AutomationNode( - id: json['id']?.toString() ?? '', - kind: kind, - path: json['path']?.toString() ?? '', - commandText: json['commandText']?.toString() ?? '', - cwd: json['cwd']?.toString() ?? '', - directory: json['directory']?.toString() ?? '', - conditionToken: json['conditionToken']?.toString() ?? '', - whenTrue: AutomationBranchOutcome.values.firstWhere( - (value) => value.name == json['whenTrue']?.toString(), - orElse: () => AutomationBranchOutcome.continueFlow, - ), - whenFalse: AutomationBranchOutcome.values.firstWhere( - (value) => value.name == json['whenFalse']?.toString(), - orElse: () => AutomationBranchOutcome.quitFlow, - ), - ); - } -} - -class AutomationDefinition { - const AutomationDefinition({ - required this.id, - required this.name, - required this.enabled, - required this.nodes, - this.ownerThreadId = '', - }); - - final String id; - final String name; - final bool enabled; - final List nodes; - final String ownerThreadId; - - AutomationNode? get triggerNode { - for (final node in nodes) { - if (node.kind.isTrigger) { - return node; - } - } - return null; - } - - List get actionNodes { - return nodes.where((node) => !node.kind.isTrigger).toList(growable: false); - } - - AutomationDefinition copyWith({ - String? id, - String? name, - bool? enabled, - List? nodes, - String? ownerThreadId, - }) { - return AutomationDefinition( - id: id ?? this.id, - name: name ?? this.name, - enabled: enabled ?? this.enabled, - nodes: nodes ?? this.nodes, - ownerThreadId: ownerThreadId ?? this.ownerThreadId, - ); - } - - Map toJson() { - return { - 'id': id, - 'name': name, - 'enabled': enabled, - 'nodes': nodes.map((node) => node.toJson()).toList(), - 'ownerThreadId': ownerThreadId, - }; - } - - factory AutomationDefinition.fromJson(Map json) { - final rawNodes = json['nodes']; - final nodes = []; - if (rawNodes is List) { - for (final item in rawNodes) { - if (item is Map) { - nodes.add(AutomationNode.fromJson(item)); - } - } - } - return AutomationDefinition( - id: json['id']?.toString() ?? '', - name: json['name']?.toString() ?? '', - enabled: json['enabled'] != false, - nodes: nodes, - ownerThreadId: json['ownerThreadId']?.toString() ?? '', - ); - } -} +export 'features/settings/domain/app_settings.dart'; +export 'features/workspace/domain/workspace_models.dart'; +export 'features/commands/domain/command_models.dart'; +export 'features/automations/domain/automation_models.dart'; diff --git a/lib/src/settings_store.dart b/lib/src/settings_store.dart index bfe6633..99ee23f 100644 --- a/lib/src/settings_store.dart +++ b/lib/src/settings_store.dart @@ -1,285 +1 @@ -import 'dart:convert'; - -import 'package:shared_preferences/shared_preferences.dart'; - -import 'models.dart'; - -class SettingsStore { - Future load() async { - final prefs = await SharedPreferences.getInstance(); - final defaults = AppSettings.defaults(); - return defaults.copyWith( - connectionMode: ConnectionMode.values.byName( - prefs.getString(_connectionModeKey) ?? defaults.connectionMode.name, - ), - serverUrl: prefs.getString(_serverUrlKey) ?? defaults.serverUrl, - websocketBearerToken: - prefs.getString(_websocketBearerTokenKey) ?? - defaults.websocketBearerToken, - relayUrl: prefs.getString(_relayUrlKey) ?? defaults.relayUrl, - relayDeviceId: - prefs.getString(_relayDeviceIdKey) ?? defaults.relayDeviceId, - relayBridgeLabel: - prefs.getString(_relayBridgeLabelKey) ?? defaults.relayBridgeLabel, - relayBridgeSigningPublicKey: - prefs.getString(_relayBridgeSigningPublicKeyKey) ?? - defaults.relayBridgeSigningPublicKey, - relayClientPrivateKey: - prefs.getString(_relayClientPrivateKeyKey) ?? - defaults.relayClientPrivateKey, - relayClientPublicKey: - prefs.getString(_relayClientPublicKeyKey) ?? - defaults.relayClientPublicKey, - model: prefs.getString(_modelKey) ?? defaults.model, - reasoningEffort: - prefs.getString(_reasoningEffortKey) ?? defaults.reasoningEffort, - planMode: prefs.getBool(_planModeKey) ?? defaults.planMode, - approvalPolicy: normalizeApprovalPolicy( - prefs.getString(_approvalPolicyKey) ?? defaults.approvalPolicy, - ), - sandboxMode: SandboxMode.values.byName( - prefs.getString(_sandboxModeKey) ?? defaults.sandboxMode.name, - ), - allowNetwork: prefs.getBool(_allowNetworkKey) ?? defaults.allowNetwork, - themePreference: ThemePreference.values.byName( - prefs.getString(_themePreferenceKey) ?? defaults.themePreference.name, - ), - resumeThreadId: - prefs.getString(_resumeThreadIdKey) ?? defaults.resumeThreadId, - favoriteThreadIds: _readFavoriteThreadIds(prefs), - threadDownloadDirectories: _readThreadDownloadDirectories(prefs), - automationSnapshots: _readAutomationSnapshots(prefs), - automations: _readAutomations(prefs), - ); - } - - Future save(AppSettings settings) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_connectionModeKey, settings.connectionMode.name); - await prefs.setString(_serverUrlKey, settings.serverUrl); - await prefs.setString( - _websocketBearerTokenKey, - settings.websocketBearerToken, - ); - await prefs.setString(_relayUrlKey, settings.relayUrl); - await prefs.setString(_relayDeviceIdKey, settings.relayDeviceId); - await prefs.setString(_relayBridgeLabelKey, settings.relayBridgeLabel); - await prefs.setString( - _relayBridgeSigningPublicKeyKey, - settings.relayBridgeSigningPublicKey, - ); - await prefs.setString( - _relayClientPrivateKeyKey, - settings.relayClientPrivateKey, - ); - await prefs.setString( - _relayClientPublicKeyKey, - settings.relayClientPublicKey, - ); - await prefs.remove(_cwdKey); - await prefs.setString(_modelKey, settings.model); - await prefs.setString(_reasoningEffortKey, settings.reasoningEffort); - await prefs.setBool(_planModeKey, settings.planMode); - await prefs.setString( - _approvalPolicyKey, - normalizeApprovalPolicy(settings.approvalPolicy), - ); - await prefs.setString(_sandboxModeKey, settings.sandboxMode.name); - await prefs.setBool(_allowNetworkKey, settings.allowNetwork); - await prefs.setString(_themePreferenceKey, settings.themePreference.name); - await prefs.setString(_resumeThreadIdKey, settings.resumeThreadId); - await prefs.setString( - _favoriteThreadIdsKey, - jsonEncode(settings.favoriteThreadIds), - ); - await prefs.setString( - _threadDownloadDirectoriesKey, - jsonEncode(settings.threadDownloadDirectories), - ); - await prefs.setString( - _automationSnapshotsKey, - jsonEncode(settings.automationSnapshots), - ); - await prefs.setString( - _automationsKey, - jsonEncode( - settings.automations - .map((automation) => automation.toJson()) - .toList(growable: false), - ), - ); - } - - Map _readThreadDownloadDirectories(SharedPreferences prefs) { - final raw = prefs.getString(_threadDownloadDirectoriesKey); - if (raw == null || raw.trim().isEmpty) { - return {}; - } - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) { - return {}; - } - return decoded.map((key, value) { - return MapEntry(key, value?.toString() ?? ''); - })..removeWhere( - (key, value) => key.trim().isEmpty || value.trim().isEmpty, - ); - } catch (_) { - return {}; - } - } - - List _readFavoriteThreadIds(SharedPreferences prefs) { - final raw = prefs.getString(_favoriteThreadIdsKey); - if (raw == null || raw.trim().isEmpty) { - return const []; - } - try { - final decoded = jsonDecode(raw); - if (decoded is! List) { - return const []; - } - return decoded - .map((item) => item?.toString() ?? '') - .where((item) => item.trim().isNotEmpty) - .toList(growable: false); - } catch (_) { - return const []; - } - } - - Map> _readAutomationSnapshots( - SharedPreferences prefs, - ) { - final raw = prefs.getString(_automationSnapshotsKey); - if (raw == null || raw.trim().isEmpty) { - return >{}; - } - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) { - return >{}; - } - final result = >{}; - for (final entry in decoded.entries) { - if (entry.key.trim().isEmpty || entry.value is! Map) { - continue; - } - final snapshotMap = {}; - for (final snapshotEntry - in (entry.value as Map).entries) { - final key = snapshotEntry.key.trim(); - final value = snapshotEntry.value?.toString() ?? ''; - if (key.isEmpty || value.trim().isEmpty) { - continue; - } - snapshotMap[key] = value; - } - result[entry.key] = snapshotMap; - } - return result; - } catch (_) { - return >{}; - } - } - - Future> loadRecentCommands() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getStringList(_recentCommandsKey) ?? const []; - final items = []; - for (final encoded in raw) { - try { - final decoded = jsonDecode(encoded); - if (decoded is! Map) { - continue; - } - items.add( - RecentCommand( - commandText: decoded['commandText']?.toString() ?? '', - cwd: decoded['cwd']?.toString() ?? '', - mode: CommandSessionMode.values.byName( - decoded['mode']?.toString() ?? CommandSessionMode.buffered.name, - ), - sandboxMode: SandboxMode.values.byName( - decoded['sandboxMode']?.toString() ?? - SandboxMode.workspaceWrite.name, - ), - allowNetwork: decoded['allowNetwork'] == true, - disableTimeout: decoded['disableTimeout'] != false, - timeoutMs: decoded['timeoutMs'] as int? ?? 60000, - disableOutputCap: decoded['disableOutputCap'] != false, - outputBytesCap: decoded['outputBytesCap'] as int? ?? 32768, - ), - ); - } catch (_) { - continue; - } - } - return items; - } - - Future saveRecentCommands(List commands) async { - final prefs = await SharedPreferences.getInstance(); - final encoded = commands - .map( - (command) => jsonEncode({ - 'commandText': command.commandText, - 'cwd': command.cwd, - 'mode': command.mode.name, - 'sandboxMode': command.sandboxMode.name, - 'allowNetwork': command.allowNetwork, - 'disableTimeout': command.disableTimeout, - 'timeoutMs': command.timeoutMs, - 'disableOutputCap': command.disableOutputCap, - 'outputBytesCap': command.outputBytesCap, - }), - ) - .toList(); - await prefs.setStringList(_recentCommandsKey, encoded); - } - - List _readAutomations(SharedPreferences prefs) { - final raw = prefs.getString(_automationsKey); - if (raw == null || raw.trim().isEmpty) { - return const []; - } - try { - final decoded = jsonDecode(raw); - if (decoded is! List) { - return const []; - } - return decoded - .whereType>() - .map(AutomationDefinition.fromJson) - .where((automation) => automation.id.trim().isNotEmpty) - .toList(growable: false); - } catch (_) { - return const []; - } - } -} - -const _connectionModeKey = 'connection_mode'; -const _serverUrlKey = 'server_url'; -const _websocketBearerTokenKey = 'websocket_bearer_token'; -const _relayUrlKey = 'relay_url'; -const _relayDeviceIdKey = 'relay_device_id'; -const _relayBridgeLabelKey = 'relay_bridge_label'; -const _relayBridgeSigningPublicKeyKey = 'relay_bridge_signing_public_key'; -const _relayClientPrivateKeyKey = 'relay_client_private_key'; -const _relayClientPublicKeyKey = 'relay_client_public_key'; -const _cwdKey = 'cwd'; -const _modelKey = 'model'; -const _reasoningEffortKey = 'reasoning_effort'; -const _planModeKey = 'plan_mode'; -const _approvalPolicyKey = 'approval_policy'; -const _sandboxModeKey = 'sandbox_mode'; -const _allowNetworkKey = 'allow_network'; -const _themePreferenceKey = 'theme_preference'; -const _resumeThreadIdKey = 'resume_thread_id'; -const _favoriteThreadIdsKey = 'favorite_thread_ids'; -const _threadDownloadDirectoriesKey = 'thread_download_directories'; -const _automationSnapshotsKey = 'automation_snapshots'; -const _automationsKey = 'automations'; -const _recentCommandsKey = 'recent_commands'; +export 'core/infrastructure/settings_store.dart'; diff --git a/lib/src/transport.dart b/lib/src/transport.dart index 183dbaf..136f211 100644 --- a/lib/src/transport.dart +++ b/lib/src/transport.dart @@ -1,1116 +1 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import 'package:cryptography/cryptography.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:web_socket_channel/io.dart'; - -import 'models.dart'; - -const String _androidEventChannelName = 'codex_remote/android_events'; -const String _androidMethodChannelName = 'codex_remote/android_transport'; - -abstract class AppTransport { - Stream get messages; - bool get isConnected; - Future connect(AppSettings settings); - Future disconnect(); - Future send(String payload); -} - -class PlatformAdaptiveTransport implements AppTransport { - final AppTransport _directTransport; - final AppTransport _relayTransport; - final StreamController _messages = - StreamController.broadcast(); - AppTransport? _active; - - PlatformAdaptiveTransport({ - required AppTransport directTransport, - required AppTransport relayTransport, - }) : _directTransport = directTransport, - _relayTransport = relayTransport { - _directTransport.messages.listen( - _messages.add, - onError: _messages.addError, - ); - _relayTransport.messages.listen( - _messages.add, - onError: _messages.addError, - ); - } - - @override - Stream get messages => _messages.stream; - - @override - bool get isConnected => _active?.isConnected ?? false; - - @override - Future connect(AppSettings settings) async { - final next = settings.connectionMode == ConnectionMode.relay - ? _relayTransport - : _directTransport; - if (_active != null && !identical(_active, next)) { - await _active!.disconnect(); - } - _active = next; - await next.connect(settings); - } - - @override - Future disconnect() async { - await _active?.disconnect(); - } - - @override - Future send(String payload) async { - await _active?.send(payload); - } -} - -class DirectWebSocketTransport implements AppTransport { - final StreamController _messages = - StreamController.broadcast(); - IOWebSocketChannel? _channel; - StreamSubscription? _subscription; - bool _connected = false; - - @override - Stream get messages => _messages.stream; - - @override - bool get isConnected => _connected; - - void _notifyUnexpectedDisconnect() { - _messages.addError(StateError('Transport disconnected.')); - } - - @override - Future connect(AppSettings settings) async { - await disconnect(); - final authToken = settings.websocketBearerToken.trim(); - final socket = await WebSocket.connect( - settings.serverUrl, - headers: authToken.isEmpty - ? null - : {'Authorization': 'Bearer $authToken'}, - ); - socket.pingInterval = const Duration(seconds: 20); - _channel = IOWebSocketChannel(socket); - _subscription = _channel!.stream.listen( - (dynamic event) { - if (event is String) { - _messages.add(event); - } - }, - onError: _messages.addError, - onDone: () { - final wasConnected = _connected; - _connected = false; - if (wasConnected) { - _notifyUnexpectedDisconnect(); - } - }, - ); - _connected = true; - } - - @override - Future disconnect() async { - _connected = false; - await _subscription?.cancel(); - _subscription = null; - await _channel?.sink.close(); - _channel = null; - } - - @override - Future send(String payload) async { - _channel?.sink.add(payload); - } -} - -class AndroidForegroundTransport implements AppTransport { - AndroidForegroundTransport() - : _events = const EventChannel(_androidEventChannelName), - _methods = const MethodChannel(_androidMethodChannelName); - - final EventChannel _events; - final MethodChannel _methods; - final StreamController _messages = - StreamController.broadcast(); - StreamSubscription? _subscription; - Completer? _readyCompleter; - bool _connected = false; - - @override - Stream get messages => _messages.stream; - - @override - bool get isConnected => _connected; - - void _notifyUnexpectedDisconnect() { - _messages.addError(StateError('Transport disconnected.')); - } - - @override - Future connect(AppSettings settings) async { - await _subscription?.cancel(); - _connected = false; - final readyCompleter = Completer(); - _readyCompleter = readyCompleter; - _subscription = _events.receiveBroadcastStream().listen( - (dynamic event) { - if (event is! String) { - return; - } - _handleTransportEvent(event, readyCompleter); - _messages.add(event); - }, - onError: (Object error, StackTrace stackTrace) { - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(error, stackTrace); - } - _messages.addError(error, stackTrace); - }, - onDone: () { - final wasConnected = _connected; - _connected = false; - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(StateError('Transport disconnected.')); - } else if (wasConnected) { - _notifyUnexpectedDisconnect(); - } - }, - ); - final authToken = settings.websocketBearerToken.trim(); - await _methods.invokeMethod('connect', { - 'url': settings.serverUrl, - 'bearerToken': authToken, - }); - await readyCompleter.future.timeout(const Duration(seconds: 15)); - } - - @override - Future disconnect() async { - final readyCompleter = _readyCompleter; - if (readyCompleter != null && !readyCompleter.isCompleted) { - readyCompleter.completeError(StateError('Transport disconnected.')); - } - _readyCompleter = null; - await _methods.invokeMethod('disconnect'); - _connected = false; - } - - @override - Future send(String payload) async { - final readyCompleter = _readyCompleter; - if (readyCompleter == null) { - throw StateError('Android foreground transport is not connected.'); - } - await readyCompleter.future; - await _methods.invokeMethod('send', { - 'payload': payload, - }); - } - - void _handleTransportEvent(String event, Completer readyCompleter) { - dynamic decoded; - try { - decoded = jsonDecode(event); - } catch (_) { - return; - } - if (decoded is! Map) { - return; - } - if (decoded['method'] != 'android/transportStatus') { - return; - } - final params = decoded['params']; - if (params is! Map) { - return; - } - final status = params['status']?.toString(); - switch (status) { - case 'connected': - _connected = true; - if (!readyCompleter.isCompleted) { - readyCompleter.complete(); - } - break; - case 'disconnected': - _connected = false; - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(StateError('Transport disconnected.')); - } - break; - case 'error': - _connected = false; - if (!readyCompleter.isCompleted) { - readyCompleter.completeError( - StateError( - params['message']?.toString() ?? 'Android transport error.', - ), - ); - } - break; - } - } -} - -class AndroidRelaySecureTransport implements AppTransport { - AndroidRelaySecureTransport() - : _events = const EventChannel(_androidEventChannelName), - _methods = const MethodChannel(_androidMethodChannelName); - - final EventChannel _events; - final MethodChannel _methods; - final StreamController _messages = - StreamController.broadcast(); - final Ed25519 _signing = Ed25519(); - final X25519 _keyAgreement = X25519(); - final Cipher _cipher = Chacha20.poly1305Aead(); - StreamSubscription? _subscription; - Completer? _readyCompleter; - bool _connected = false; - AppSettings? _settings; - KeyPair? _sessionKeyPair; - SecretKey? _sessionSecretKey; - String? _sessionId; - String? _sessionNonce; - int _sendCounter = 0; - int _receiveCounter = 0; - - @override - Stream get messages => _messages.stream; - - @override - bool get isConnected => _connected; - - void _notifyUnexpectedDisconnect() { - _messages.addError(StateError('Transport disconnected.')); - } - - @override - Future connect(AppSettings settings) async { - await disconnect(); - if (settings.relayUrl.trim().isEmpty || - settings.relayDeviceId.trim().isEmpty || - settings.relayClientPrivateKey.trim().isEmpty || - settings.relayClientPublicKey.trim().isEmpty || - settings.relayBridgeSigningPublicKey.trim().isEmpty) { - throw StateError('Relay mode is selected, but relay pairing is missing.'); - } - final relayUri = Uri.tryParse(settings.relayUrl); - if (relayUri == null || !relayUri.hasScheme || relayUri.host.isEmpty) { - throw StateError('Relay URL is invalid.'); - } - if (!_isAllowedRelayUri(relayUri)) { - throw StateError('Relay mode requires HTTPS for non-local relay servers.'); - } - _settings = settings; - final readyCompleter = Completer(); - _readyCompleter = readyCompleter; - _subscription = _events.receiveBroadcastStream().listen( - (dynamic event) { - if (event is! String) { - return; - } - if (_handleTransportEvent(event, readyCompleter)) { - return; - } - unawaited(_handleRelayMessage(event)); - }, - onError: (Object error, StackTrace stackTrace) { - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(error, stackTrace); - } else { - _messages.addError(error, stackTrace); - } - }, - onDone: () { - final wasConnected = _connected; - _connected = false; - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(StateError('Transport disconnected.')); - } else if (wasConnected) { - _notifyUnexpectedDisconnect(); - } - }, - ); - await _methods.invokeMethod('connect', { - 'url': relayWebSocketUri(relayUri).toString(), - 'bearerToken': null, - }); - await readyCompleter.future.timeout(const Duration(seconds: 20)); - _connected = true; - } - - @override - Future disconnect() async { - _connected = false; - _sendCounter = 0; - _receiveCounter = 0; - _sessionId = null; - _sessionNonce = null; - _sessionKeyPair = null; - _sessionSecretKey = null; - _settings = null; - final ready = _readyCompleter; - if (ready != null && !ready.isCompleted) { - ready.completeError(StateError('Transport disconnected.')); - } - _readyCompleter = null; - await _subscription?.cancel(); - _subscription = null; - await _methods.invokeMethod('disconnect'); - } - - @override - Future send(String payload) async { - final ready = _readyCompleter; - if (ready == null) { - throw StateError('Relay transport is not connected.'); - } - await ready.future; - final secretKey = _sessionSecretKey; - final sessionId = _sessionId; - final settings = _settings; - if (secretKey == null || sessionId == null || settings == null) { - throw StateError('Relay session is not ready.'); - } - final counter = _sendCounter++; - final aad = _relayAad( - counter: counter, - deviceId: settings.relayDeviceId, - sessionId: sessionId, - ); - final secretBox = await _cipher.encrypt( - utf8.encode(payload), - secretKey: secretKey, - nonce: _nonceFor(prefix: 'CLNT', counter: counter), - aad: aad, - ); - final combined = Uint8List.fromList( - [...secretBox.cipherText, ...secretBox.mac.bytes], - ); - await _sendRaw( - jsonEncode({ - 'counter': counter, - 'ciphertext': _b64urlEncode(combined), - 'sessionId': sessionId, - 'type': 'relay_frame', - }), - ); - } - - bool _handleTransportEvent(String event, Completer readyCompleter) { - dynamic decoded; - try { - decoded = jsonDecode(event); - } catch (_) { - return false; - } - if (decoded is! Map) { - return false; - } - if (decoded['method'] != 'android/transportStatus') { - return false; - } - final params = decoded['params']; - if (params is! Map) { - return true; - } - final status = params['status']?.toString(); - switch (status) { - case 'connected': - break; - case 'disconnected': - _connected = false; - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(StateError('Transport disconnected.')); - } else { - _messages.addError(StateError('Transport disconnected.')); - } - break; - case 'error': - _connected = false; - final message = - params['message']?.toString() ?? 'Android transport error.'; - if (!readyCompleter.isCompleted) { - readyCompleter.completeError(StateError(message)); - } else { - _messages.addError(StateError(message)); - } - break; - } - return true; - } - - Future _sendRaw(String payload) async { - await _methods.invokeMethod('send', { - 'payload': payload, - }); - } - - Future _handleRelayMessage(String event) async { - final payload = jsonDecode(event) as Map; - switch (payload['type']) { - case 'challenge': - await _respondToChallenge(payload); - case 'authenticated': - break; - case 'session_open': - await _completeSession(payload); - case 'relay_frame': - await _handleEncryptedFrame(payload); - case 'close_session': - final sessionId = payload['sessionId']?.toString(); - if (sessionId == null || sessionId == _sessionId) { - _messages.addError(StateError('Relay session closed by peer.')); - } - default: - break; - } - } - - Future _respondToChallenge(Map payload) async { - final settings = _settings; - if (settings == null) { - return; - } - final authNonce = _randomToken(12); - final authTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final sessionKeyPair = await _keyAgreement.newKeyPair(); - final sessionKeyPairData = await sessionKeyPair.extract(); - final sessionPublicKey = await sessionKeyPair.extractPublicKey(); - final sessionNonce = _randomToken(12); - final signedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final signingKeyPair = _clientSigningKeyPair(settings); - final authSignature = await _signing.sign( - _canonicalJson({ - 'authNonce': authNonce, - 'authTimestamp': authTimestamp, - 'challenge': payload['challenge'], - 'connectionId': payload['connectionId'], - 'deviceId': settings.relayDeviceId, - 'role': 'client', - 'type': 'codex-remote-auth-v1', - }), - keyPair: signingKeyPair, - ); - final sessionSignature = await _signing.sign( - _canonicalJson({ - 'deviceId': settings.relayDeviceId, - 'role': 'client', - 'sessionNonce': sessionNonce, - 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), - 'signedAt': signedAt, - 'type': 'codex-remote-session-bundle-v1', - }), - keyPair: signingKeyPair, - ); - _sessionKeyPair = sessionKeyPairData; - _sessionNonce = sessionNonce; - await _sendRaw( - jsonEncode({ - 'authNonce': authNonce, - 'authSignature': _b64urlEncode(authSignature.bytes), - 'authTimestamp': authTimestamp, - 'deviceId': settings.relayDeviceId, - 'role': 'client', - 'sessionBundle': { - 'sessionNonce': sessionNonce, - 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), - 'signature': _b64urlEncode(sessionSignature.bytes), - 'signedAt': signedAt, - }, - 'type': 'authenticate', - }), - ); - } - - Future _completeSession(Map payload) async { - final settings = _settings; - final sessionKeyPair = _sessionKeyPair; - final sessionNonce = _sessionNonce; - if (settings == null || sessionKeyPair == null || sessionNonce == null) { - return; - } - final peerSigningKey = payload['peerSigningPublicKey']?.toString() ?? ''; - if (peerSigningKey != settings.relayBridgeSigningPublicKey) { - throw StateError('Relay bridge identity does not match the paired bridge.'); - } - final bundle = payload['peerSessionBundle'] as Map; - final peerSessionPublicKey = bundle['sessionPublicKey']?.toString() ?? ''; - final peerSessionNonce = bundle['sessionNonce']?.toString() ?? ''; - final signatureBytes = _b64urlDecode(bundle['signature']?.toString() ?? ''); - final verified = await _signing.verify( - _canonicalJson({ - 'deviceId': settings.relayDeviceId, - 'role': 'bridge', - 'sessionNonce': peerSessionNonce, - 'sessionPublicKey': peerSessionPublicKey, - 'signedAt': bundle['signedAt'], - 'type': 'codex-remote-session-bundle-v1', - }), - signature: Signature( - signatureBytes, - publicKey: SimplePublicKey( - _b64urlDecode(peerSigningKey), - type: KeyPairType.ed25519, - ), - ), - ); - if (!verified) { - throw StateError('Bridge session signature verification failed.'); - } - final sharedSecret = await _keyAgreement.sharedSecretKey( - keyPair: sessionKeyPair, - remotePublicKey: SimplePublicKey( - _b64urlDecode(peerSessionPublicKey), - type: KeyPairType.x25519, - ), - ); - final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); - final salt = await deriveRelaySaltBytes( - localNonce: sessionNonce, - peerNonce: peerSessionNonce, - ); - _sessionSecretKey = await hkdf.deriveKey( - secretKey: sharedSecret, - nonce: salt, - info: utf8.encode(settings.relayDeviceId), - ); - final sessionId = payload['sessionId']?.toString(); - if (sessionId == null || sessionId.isEmpty) { - throw StateError('Relay did not provide a session identifier.'); - } - _sessionId = sessionId; - _sendCounter = 0; - _receiveCounter = 0; - if (!(_readyCompleter?.isCompleted ?? true)) { - _readyCompleter?.complete(); - } - } - - Future _handleEncryptedFrame(Map payload) async { - final secretKey = _sessionSecretKey; - final sessionId = _sessionId; - final settings = _settings; - if (secretKey == null || sessionId == null || settings == null) { - return; - } - final messageSessionId = payload['sessionId']?.toString(); - if (messageSessionId != sessionId) { - return; - } - final counter = payload['counter'] as int? ?? -1; - if (counter != _receiveCounter) { - throw StateError( - 'Unexpected relay frame counter: expected $_receiveCounter, received $counter.', - ); - } - _receiveCounter += 1; - final combined = _b64urlDecode(payload['ciphertext']?.toString() ?? ''); - if (combined.length < 16) { - throw StateError('Relay ciphertext is truncated.'); - } - final cipherText = combined.sublist(0, combined.length - 16); - final mac = Mac(combined.sublist(combined.length - 16)); - final secretBox = SecretBox( - cipherText, - nonce: _nonceFor(prefix: 'BRDG', counter: counter), - mac: mac, - ); - final plainBytes = await _cipher.decrypt( - secretBox, - secretKey: secretKey, - aad: _relayAad( - counter: counter, - deviceId: settings.relayDeviceId, - sessionId: sessionId, - ), - ); - _messages.add(utf8.decode(plainBytes)); - } - - Uint8List _relayAad({ - required int counter, - required String deviceId, - required String sessionId, - }) { - return Uint8List.fromList( - _canonicalJson({ - 'counter': counter, - 'deviceId': deviceId, - 'sessionId': sessionId, - 'type': 'relay-frame-v1', - }), - ); - } - - SimpleKeyPairData _clientSigningKeyPair(AppSettings settings) { - return SimpleKeyPairData( - _b64urlDecode(settings.relayClientPrivateKey), - publicKey: SimplePublicKey( - _b64urlDecode(settings.relayClientPublicKey), - type: KeyPairType.ed25519, - ), - type: KeyPairType.ed25519, - ); - } -} - -class RelaySecureTransport implements AppTransport { - final StreamController _messages = - StreamController.broadcast(); - final Ed25519 _signing = Ed25519(); - final X25519 _keyAgreement = X25519(); - final Cipher _cipher = Chacha20.poly1305Aead(); - IOWebSocketChannel? _channel; - StreamSubscription? _subscription; - Completer? _readyCompleter; - bool _connected = false; - AppSettings? _settings; - KeyPair? _sessionKeyPair; - SecretKey? _sessionSecretKey; - String? _sessionId; - String? _sessionNonce; - int _sendCounter = 0; - int _receiveCounter = 0; - - @override - Stream get messages => _messages.stream; - - @override - bool get isConnected => _connected; - - void _notifyUnexpectedDisconnect() { - _messages.addError(StateError('Transport disconnected.')); - } - - @override - Future connect(AppSettings settings) async { - await disconnect(); - if (settings.relayUrl.trim().isEmpty || - settings.relayDeviceId.trim().isEmpty || - settings.relayClientPrivateKey.trim().isEmpty || - settings.relayClientPublicKey.trim().isEmpty || - settings.relayBridgeSigningPublicKey.trim().isEmpty) { - throw StateError('Relay mode is selected, but relay pairing is missing.'); - } - final relayUri = Uri.tryParse(settings.relayUrl); - if (relayUri == null || !relayUri.hasScheme || relayUri.host.isEmpty) { - throw StateError('Relay URL is invalid.'); - } - if (!_isAllowedRelayUri(relayUri)) { - throw StateError('Relay mode requires HTTPS for non-local relay servers.'); - } - _settings = settings; - _readyCompleter = Completer(); - _channel = IOWebSocketChannel.connect( - relayWebSocketUri(relayUri), - pingInterval: const Duration(seconds: 20), - connectTimeout: const Duration(seconds: 15), - ); - _subscription = _channel!.stream.listen( - _handleRelayMessage, - onError: (Object error, StackTrace stackTrace) { - if (!(_readyCompleter?.isCompleted ?? true)) { - _readyCompleter?.completeError(error, stackTrace); - } else { - _messages.addError(error, stackTrace); - } - }, - onDone: () { - final wasConnected = _connected; - _connected = false; - if (wasConnected) { - _notifyUnexpectedDisconnect(); - } - }, - ); - await _readyCompleter!.future.timeout(const Duration(seconds: 20)); - _connected = true; - } - - @override - Future disconnect() async { - _connected = false; - _sendCounter = 0; - _receiveCounter = 0; - _sessionId = null; - _sessionNonce = null; - _sessionKeyPair = null; - _sessionSecretKey = null; - _settings = null; - final ready = _readyCompleter; - if (ready != null && !ready.isCompleted) { - ready.completeError(StateError('Transport disconnected.')); - } - _readyCompleter = null; - await _subscription?.cancel(); - _subscription = null; - await _channel?.sink.close(); - _channel = null; - } - - @override - Future send(String payload) async { - final ready = _readyCompleter; - if (ready == null) { - throw StateError('Relay transport is not connected.'); - } - await ready.future; - final secretKey = _sessionSecretKey; - final sessionId = _sessionId; - final settings = _settings; - if (secretKey == null || sessionId == null || settings == null) { - throw StateError('Relay session is not ready.'); - } - final counter = _sendCounter++; - final aad = _relayAad( - counter: counter, - deviceId: settings.relayDeviceId, - sessionId: sessionId, - ); - final secretBox = await _cipher.encrypt( - utf8.encode(payload), - secretKey: secretKey, - nonce: _nonceFor(prefix: 'CLNT', counter: counter), - aad: aad, - ); - final combined = Uint8List.fromList( - [...secretBox.cipherText, ...secretBox.mac.bytes], - ); - _channel?.sink.add( - jsonEncode({ - 'counter': counter, - 'ciphertext': _b64urlEncode(combined), - 'sessionId': sessionId, - 'type': 'relay_frame', - }), - ); - } - - Future _handleRelayMessage(dynamic event) async { - if (event is! String) { - return; - } - final payload = jsonDecode(event) as Map; - switch (payload['type']) { - case 'challenge': - await _respondToChallenge(payload); - case 'authenticated': - break; - case 'session_open': - await _completeSession(payload); - case 'relay_frame': - await _handleEncryptedFrame(payload); - case 'close_session': - final sessionId = payload['sessionId']?.toString(); - if (sessionId == null || sessionId == _sessionId) { - _messages.addError(StateError('Relay session closed by peer.')); - } - default: - break; - } - } - - Future _respondToChallenge(Map payload) async { - final settings = _settings; - if (settings == null) { - return; - } - final authNonce = _randomToken(12); - final authTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final sessionKeyPair = await _keyAgreement.newKeyPair(); - final sessionKeyPairData = await sessionKeyPair.extract(); - final sessionPublicKey = await sessionKeyPair.extractPublicKey(); - final sessionNonce = _randomToken(12); - final signedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final signingKeyPair = _clientSigningKeyPair(settings); - final authSignature = await _signing.sign( - _canonicalJson({ - 'authNonce': authNonce, - 'authTimestamp': authTimestamp, - 'challenge': payload['challenge'], - 'connectionId': payload['connectionId'], - 'deviceId': settings.relayDeviceId, - 'role': 'client', - 'type': 'codex-remote-auth-v1', - }), - keyPair: signingKeyPair, - ); - final sessionSignature = await _signing.sign( - _canonicalJson({ - 'deviceId': settings.relayDeviceId, - 'role': 'client', - 'sessionNonce': sessionNonce, - 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), - 'signedAt': signedAt, - 'type': 'codex-remote-session-bundle-v1', - }), - keyPair: signingKeyPair, - ); - _sessionKeyPair = sessionKeyPairData; - _sessionNonce = sessionNonce; - _channel?.sink.add( - jsonEncode({ - 'authNonce': authNonce, - 'authSignature': _b64urlEncode(authSignature.bytes), - 'authTimestamp': authTimestamp, - 'deviceId': settings.relayDeviceId, - 'role': 'client', - 'sessionBundle': { - 'sessionNonce': sessionNonce, - 'sessionPublicKey': _b64urlEncode(sessionPublicKey.bytes), - 'signature': _b64urlEncode(sessionSignature.bytes), - 'signedAt': signedAt, - }, - 'type': 'authenticate', - }), - ); - } - - Future _completeSession(Map payload) async { - final settings = _settings; - final sessionKeyPair = _sessionKeyPair; - final sessionNonce = _sessionNonce; - if (settings == null || sessionKeyPair == null || sessionNonce == null) { - return; - } - final peerSigningKey = payload['peerSigningPublicKey']?.toString() ?? ''; - if (peerSigningKey != settings.relayBridgeSigningPublicKey) { - throw StateError('Relay bridge identity does not match the paired bridge.'); - } - final bundle = payload['peerSessionBundle'] as Map; - final peerSessionPublicKey = bundle['sessionPublicKey']?.toString() ?? ''; - final peerSessionNonce = bundle['sessionNonce']?.toString() ?? ''; - final signatureBytes = _b64urlDecode(bundle['signature']?.toString() ?? ''); - final verified = await _signing.verify( - _canonicalJson({ - 'deviceId': settings.relayDeviceId, - 'role': 'bridge', - 'sessionNonce': peerSessionNonce, - 'sessionPublicKey': peerSessionPublicKey, - 'signedAt': bundle['signedAt'], - 'type': 'codex-remote-session-bundle-v1', - }), - signature: Signature( - signatureBytes, - publicKey: SimplePublicKey( - _b64urlDecode(peerSigningKey), - type: KeyPairType.ed25519, - ), - ), - ); - if (!verified) { - throw StateError('Bridge session signature verification failed.'); - } - final sharedSecret = await _keyAgreement.sharedSecretKey( - keyPair: sessionKeyPair, - remotePublicKey: SimplePublicKey( - _b64urlDecode(peerSessionPublicKey), - type: KeyPairType.x25519, - ), - ); - final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); - final salt = await deriveRelaySaltBytes( - localNonce: sessionNonce, - peerNonce: peerSessionNonce, - ); - _sessionSecretKey = await hkdf.deriveKey( - secretKey: sharedSecret, - nonce: salt, - info: utf8.encode(settings.relayDeviceId), - ); - final sessionId = payload['sessionId']?.toString(); - if (sessionId == null || sessionId.isEmpty) { - throw StateError('Relay did not provide a session identifier.'); - } - _sessionId = sessionId; - _sendCounter = 0; - _receiveCounter = 0; - if (!(_readyCompleter?.isCompleted ?? true)) { - _readyCompleter?.complete(); - } - } - - Future _handleEncryptedFrame(Map payload) async { - final secretKey = _sessionSecretKey; - final sessionId = _sessionId; - final settings = _settings; - if (secretKey == null || sessionId == null || settings == null) { - return; - } - final messageSessionId = payload['sessionId']?.toString(); - if (messageSessionId != sessionId) { - return; - } - final counter = payload['counter'] as int? ?? -1; - if (counter != _receiveCounter) { - throw StateError( - 'Unexpected relay frame counter: expected $_receiveCounter, received $counter.', - ); - } - _receiveCounter += 1; - final combined = _b64urlDecode(payload['ciphertext']?.toString() ?? ''); - if (combined.length < 16) { - throw StateError('Relay ciphertext is truncated.'); - } - final cipherText = combined.sublist(0, combined.length - 16); - final mac = Mac(combined.sublist(combined.length - 16)); - final secretBox = SecretBox( - cipherText, - nonce: _nonceFor(prefix: 'BRDG', counter: counter), - mac: mac, - ); - final plainBytes = await _cipher.decrypt( - secretBox, - secretKey: secretKey, - aad: _relayAad( - counter: counter, - deviceId: settings.relayDeviceId, - sessionId: sessionId, - ), - ); - _messages.add(utf8.decode(plainBytes)); - } - - Uint8List _relayAad({ - required int counter, - required String deviceId, - required String sessionId, - }) { - return Uint8List.fromList( - _canonicalJson({ - 'counter': counter, - 'deviceId': deviceId, - 'sessionId': sessionId, - 'type': 'relay-frame-v1', - }), - ); - } - - SimpleKeyPairData _clientSigningKeyPair(AppSettings settings) { - return SimpleKeyPairData( - _b64urlDecode(settings.relayClientPrivateKey), - publicKey: SimplePublicKey( - _b64urlDecode(settings.relayClientPublicKey), - type: KeyPairType.ed25519, - ), - type: KeyPairType.ed25519, - ); - } -} - -AppTransport createDefaultTransport() { - final directTransport = - !kIsWeb && defaultTargetPlatform == TargetPlatform.android - ? AndroidForegroundTransport() - : DirectWebSocketTransport(); - final relayTransport = - !kIsWeb && defaultTargetPlatform == TargetPlatform.android - ? AndroidRelaySecureTransport() - : RelaySecureTransport(); - return PlatformAdaptiveTransport( - directTransport: directTransport, - relayTransport: relayTransport, - ); -} - -bool _isAllowedRelayUri(Uri relayUri) { - if (relayUri.scheme == 'https') { - return true; - } - if (relayUri.scheme != 'http') { - return false; - } - final host = relayUri.host.toLowerCase(); - return host == 'localhost' || - host == '127.0.0.1' || - host == '::1' || - host.endsWith('.local'); -} - -@visibleForTesting -Uri relayWebSocketUri(Uri relayUri) { - final wsScheme = switch (relayUri.scheme) { - 'https' => 'wss', - 'http' => 'ws', - _ => relayUri.scheme, - }; - final normalizedPath = relayUri.path.endsWith('/') - ? '${relayUri.path}ws' - : '${relayUri.path}/ws'; - return relayUri.replace( - scheme: wsScheme, - path: normalizedPath, - ); -} - -@visibleForTesting -Future> deriveRelaySaltBytes({ - required String localNonce, - required String peerNonce, -}) async { - final sorted = [localNonce, peerNonce]..sort(); - final digest = await Sha256().hash(utf8.encode(sorted.join())); - return digest.bytes; -} - -String _b64urlEncode(List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); -} - -Uint8List _b64urlDecode(String value) { - final normalized = value.padRight((value.length + 3) ~/ 4 * 4, '='); - return Uint8List.fromList(base64Url.decode(normalized)); -} - -Uint8List _canonicalJson(Map payload) { - return Uint8List.fromList(utf8.encode(jsonEncode(_sortJson(payload)))); -} - -Object _sortJson(Object value) { - if (value is Map) { - final entries = value.entries.toList() - ..sort((MapEntry a, MapEntry b) { - return a.key.compareTo(b.key); - }); - return Map.fromEntries( - entries.map((entry) => MapEntry(entry.key, _sortJson(entry.value))), - ); - } - if (value is List) { - return value - .map((dynamic item) => _sortJson(item)) - .toList(growable: false); - } - return value; -} - -String _randomToken(int length) { - final random = Random.secure(); - final seed = Uint8List.fromList( - List.generate(length, (_) => random.nextInt(256)), - ); - return _b64urlEncode(seed); -} - -Uint8List _nonceFor({required String prefix, required int counter}) { - final bytes = ByteData(12); - final prefixBytes = ascii.encode(prefix); - for (var i = 0; i < 4; i += 1) { - bytes.setUint8(i, prefixBytes[i]); - } - bytes.setUint64(4, counter); - return bytes.buffer.asUint8List(); -} +export 'core/infrastructure/transport.dart'; diff --git a/pubspec.lock b/pubspec.lock index b8bc196..a89684f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -230,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.34" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter @@ -348,18 +356,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -528,6 +536,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" shared_preferences: dependency: "direct main" description: @@ -605,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -649,10 +673,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0bb2412..5da4ecf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: cryptography: ^2.7.0 file_picker: ^10.3.2 flutter_markdown: ^0.7.7+1 + flutter_riverpod: ^2.6.1 google_fonts: ^6.3.2 image: ^4.5.4 markdown: ^7.3.1 diff --git a/test/app_widget_test.dart b/test/app_widget_test.dart index 422ed3b..1b1012a 100644 --- a/test/app_widget_test.dart +++ b/test/app_widget_test.dart @@ -170,6 +170,28 @@ void main() { expect(find.textContaining('bold'), findsOneWidget); }); + testWidgets('context compaction items use the dedicated compacting widget', ( + WidgetTester tester, + ) async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); + await controller.connect(); + controller.activeThreadId = 'thr_1'; + transport.emitNotification('item/completed', { + 'threadId': 'thr_1', + 'turnId': 'turn_1', + 'item': { + 'id': 'context-compaction-1', + 'type': 'contextCompaction', + }, + }); + + await tester.pumpWidget(CodexRemoteApp(controller: controller)); + + expect(find.text('Context Compacting'), findsOneWidget); + expect(find.textContaining('"type": "contextCompaction"'), findsNothing); + }); + testWidgets( 'long-pressing a user message exposes copy and edit, and edit loads the composer', (WidgetTester tester) async { @@ -219,6 +241,24 @@ void main() { expect(find.text('Remote cwd'), findsNothing); }); + testWidgets('settings exposes thread load timeout field', ( + WidgetTester tester, + ) async { + final controller = AppController.testing(); + + await tester.pumpWidget(CodexRemoteApp(controller: controller)); + await tester.tap(find.byTooltip('Settings')); + await tester.pumpAndSettle(); + + expect(find.text('Thread load timeout ms'), findsOneWidget); + expect( + find.text( + 'Used for thread list, thread read, and thread resume requests.', + ), + findsOneWidget, + ); + }); + testWidgets('automation page filters to current thread by default', ( WidgetTester tester, ) async { @@ -395,46 +435,110 @@ void main() { }, ); - testWidgets( - 'thread list shows an indicator for threads with active turns', - (WidgetTester tester) async { - final transport = _FakeTransport(); - transport.threadListData = >[ + test('thread history loading uses the configured timeout', () async { + final transport = _FakeTransport() + ..delayNextThreadList = Completer() + ..threadListData = >[ { 'id': 'thr_1', - 'preview': 'Running', - 'cwd': '/workspace/active', - 'source': 'local', - 'modelProvider': 'openai', - 'status': 'inProgress', - 'name': 'Running thread', - }, - { - 'id': 'thr_idle', - 'preview': 'Idle', - 'cwd': '/workspace/idle', + 'preview': 'Thread one', + 'cwd': '/thread-cwd', 'source': 'local', 'modelProvider': 'openai', 'status': 'idle', - 'name': 'Idle thread', + 'name': 'Thread One', }, ]; + final controller = AppController.testing(transport: transport); + await controller.saveSettings( + controller.settings.copyWith(threadLoadTimeoutMs: 30), + ); + + await controller.loadThreadHistory(reset: true); + + expect(controller.threadHistory, isEmpty); + expect( + controller.threadHistoryError, + contains('Request timed out: thread/list'), + ); + }); + + testWidgets('thread list shows an indicator for threads with active turns', ( + WidgetTester tester, + ) async { + final transport = _FakeTransport(); + transport.threadListData = >[ + { + 'id': 'thr_1', + 'preview': 'Running', + 'cwd': '/workspace/active', + 'source': 'local', + 'modelProvider': 'openai', + 'status': 'inProgress', + 'name': 'Running thread', + }, + { + 'id': 'thr_idle', + 'preview': 'Idle', + 'cwd': '/workspace/idle', + 'source': 'local', + 'modelProvider': 'openai', + 'status': 'idle', + 'name': 'Idle thread', + }, + ]; + final controller = AppController.testing(transport: transport); + await controller.sendPrompt('first'); + + await tester.pumpWidget(CodexRemoteApp(controller: controller)); + await tester.tap(find.byTooltip('Threads')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect( + find.byKey(const ValueKey('thread-active-turn-thr_1')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('thread-active-turn-thr_idle')), + findsNothing, + ); + }); + + testWidgets( + 'opening threads while disconnected only opens one thread sheet after connect', + (WidgetTester tester) async { + final connectGate = Completer(); + final transport = _FakeTransport() + ..delayNextConnect = connectGate + ..threadListData = >[ + { + 'id': 'thr_1', + 'preview': 'Thread one', + 'cwd': '/thread-cwd', + 'source': 'local', + 'modelProvider': 'openai', + 'status': 'idle', + 'name': 'Thread One', + }, + ]; final controller = AppController.testing(transport: transport); - await controller.sendPrompt('first'); await tester.pumpWidget(CodexRemoteApp(controller: controller)); + await tester.tap(find.byTooltip('Threads')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byTooltip('Threads'), warnIfMissed: false); + await tester.pump(); - expect( - find.byKey(const ValueKey('thread-active-turn-thr_1')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('thread-active-turn-thr_idle')), - findsNothing, - ); + expect(transport.connectCount, 0); + + connectGate.complete(); + await tester.pumpAndSettle(); + + expect(transport.connectCount, 1); + expect(find.byType(ThreadHistorySheet), findsOneWidget); + expect(find.text('Thread One'), findsOneWidget); }, ); @@ -482,8 +586,14 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Command Center'), findsOneWidget); - expect(find.byKey(const ValueKey('command-shell-panel')), findsOneWidget); - expect(find.byKey(const ValueKey('command-shell-input')), findsOneWidget); + expect( + find.byKey(const ValueKey('command-shell-panel')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('command-shell-input')), + findsOneWidget, + ); expect(find.text('Shell history'), findsOneWidget); expect(find.byTooltip('Command settings'), findsOneWidget); expect(find.text('Setup'), findsNothing); @@ -530,6 +640,28 @@ void main() { expect(shellInput.controller?.text, 'git status'); }); + testWidgets('command center disables timeout for flutter build commands', ( + WidgetTester tester, + ) async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); + + await tester.pumpWidget(CodexRemoteApp(controller: controller)); + await tester.tap(find.byTooltip('Command')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('command-shell-input')), + 'flutter build apk --release', + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(transport.lastShellCommand, 'flutter build apk --release'); + expect(transport.lastCommandDisableTimeout, isTrue); + expect(transport.lastCommandTimeoutMs, isNull); + }); + testWidgets('shell history clear button only removes finished runs', ( WidgetTester tester, ) async { @@ -790,48 +922,45 @@ void main() { }, ); - test( - 'queued prompt waits for turn completed after final answer', - () async { - final transport = _FakeTransport(); - final controller = AppController.testing(transport: transport); - - await controller.sendPrompt('first'); - await controller.sendPrompt('second'); - expect(controller.queuedPromptCount, 1); + test('queued prompt waits for turn completed after final answer', () async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); - transport.emitNotification('item/completed', { - 'threadId': 'thr_1', - 'turnId': 'turn_1', - 'item': { - 'id': 'msg_1', - 'type': 'agentMessage', - 'text': 'done', - 'phase': 'final_answer', - }, - }); - await Future.delayed(const Duration(milliseconds: 10)); + await controller.sendPrompt('first'); + await controller.sendPrompt('second'); + expect(controller.queuedPromptCount, 1); - expect(controller.queuedPromptCount, 1); - expect(transport.turnStartCount, 1); - expect(controller.activeTurnId, 'turn_1'); + transport.emitNotification('item/completed', { + 'threadId': 'thr_1', + 'turnId': 'turn_1', + 'item': { + 'id': 'msg_1', + 'type': 'agentMessage', + 'text': 'done', + 'phase': 'final_answer', + }, + }); + await Future.delayed(const Duration(milliseconds: 10)); - transport.emitNotification('turn/completed', { - 'threadId': 'thr_1', - 'turn': { - 'id': 'turn_1', - 'status': 'completed', - 'items': [], - 'error': null, - }, - }); - await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.queuedPromptCount, 1); + expect(transport.turnStartCount, 1); + expect(controller.activeTurnId, 'turn_1'); + + transport.emitNotification('turn/completed', { + 'threadId': 'thr_1', + 'turn': { + 'id': 'turn_1', + 'status': 'completed', + 'items': [], + 'error': null, + }, + }); + await Future.delayed(const Duration(milliseconds: 10)); - expect(controller.queuedPromptCount, 0); - expect(transport.turnStartCount, 2); - expect(controller.activeTurnId, 'turn_2'); - }, - ); + expect(controller.queuedPromptCount, 0); + expect(transport.turnStartCount, 2); + expect(controller.activeTurnId, 'turn_2'); + }); test( 'controller supports explicit steering while a turn is active', @@ -1179,7 +1308,7 @@ void main() { ); test( - 'controller sends text files inline and images as image inputs', + 'controller sends text files inline and images as local image inputs', () async { final transport = _FakeTransport(); final controller = AppController.testing(transport: transport); @@ -1207,26 +1336,71 @@ void main() { final input = transport.lastTurnStartInput!; expect(input.first['type'], 'text'); expect(input.first['text'], contains('Attached file: notes.md')); - expect(input.last, { - 'type': 'image', - 'url': 'data:image/png;base64,AQID', + expect(input.last['type'], 'localImage'); + final uploadedPath = input.last['path']?.toString() ?? ''; + expect(uploadedPath, startsWith('/thread-cwd/.codex_remote_image_')); + expect(uploadedPath, endsWith('.png')); + expect( + transport._fileBytesByPath[uploadedPath], + Uint8List.fromList([1, 2, 3]), + ); + }, + ); + + test( + 'uploaded image temp files are removed after the turn completes', + () async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); + + await controller.sendPrompt( + 'Review this image', + attachments: [ + ComposerAttachment( + id: 'image-1', + fileName: 'image.png', + kind: ComposerAttachmentKind.image, + bytes: Uint8List.fromList([1, 2, 3]), + mimeType: 'image/png', + ), + ], + ); + + final uploadedPath = + transport.lastTurnStartInput!.last['path']?.toString() ?? ''; + expect(uploadedPath, isNotEmpty); + + transport.emitNotification('turn/completed', { + 'threadId': 'thr_1', + 'turn': { + 'id': 'turn_1', + 'status': 'completed', + 'error': null, + }, }); + await Future.delayed(Duration.zero); + + expect(transport.removedPaths, contains(uploadedPath)); + expect(transport._fileBytesByPath.containsKey(uploadedPath), isFalse); }, ); - test('switching threads remains possible while another turn is running', () async { - final transport = _FakeTransport(); - final controller = AppController.testing(transport: transport); + test( + 'switching threads remains possible while another turn is running', + () async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); - await controller.sendPrompt('first'); - expect(controller.threadHasActiveTurn('thr_1'), isTrue); + await controller.sendPrompt('first'); + expect(controller.threadHasActiveTurn('thr_1'), isTrue); - await controller.resumeThreadFromHistory('thr_2'); + await controller.resumeThreadFromHistory('thr_2'); - expect(controller.activeThreadId, 'thr_2'); - expect(controller.threadHasActiveTurn('thr_1'), isTrue); - expect(controller.hasActiveTurn, isTrue); - }); + expect(controller.activeThreadId, 'thr_2'); + expect(controller.threadHasActiveTurn('thr_1'), isTrue); + expect(controller.hasActiveTurn, isTrue); + }, + ); test('file download streams to the chosen directory', () async { final transport = _FakeTransport(); @@ -1392,26 +1566,29 @@ void main() { } }); - test('relay connections report the relay endpoint in the status log', () async { - final transport = _FakeTransport(); - final controller = AppController.testing(transport: transport); - await controller.saveSettings( - controller.settings.copyWith( - connectionMode: ConnectionMode.relay, - relayUrl: 'https://relay.example.com', - ), - ); + test( + 'relay connections report the relay endpoint in the status log', + () async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); + await controller.saveSettings( + controller.settings.copyWith( + connectionMode: ConnectionMode.relay, + relayUrl: 'https://relay.example.com', + ), + ); - await controller.connect(); + await controller.connect(); - expect(controller.status, ConnectionStatus.ready); - expect( - controller.entries.any( - (entry) => entry.body == 'Connected to https://relay.example.com.', - ), - isTrue, - ); - }); + expect(controller.status, ConnectionStatus.ready); + expect( + controller.entries.any( + (entry) => entry.body == 'Connected to https://relay.example.com.', + ), + isTrue, + ); + }, + ); testWidgets( 'file preview opens as a full-screen route from the file browser', @@ -1757,6 +1934,57 @@ void main() { ); }); + test( + 'reconnect preserves automation watches for the same endpoint', + () async { + final transport = _FakeTransport()..failOnDuplicateWatchPaths = true; + final controller = AppController.testing(transport: transport); + + await controller.connect(); + await controller.saveAutomation( + const AutomationDefinition( + id: 'automation_reconnect_watch', + name: 'Watch android', + enabled: true, + nodes: [ + AutomationNode( + id: 'trigger_1', + kind: AutomationNodeKind.watchDirectoryChanged, + path: '/home/ege/Documents/Projects/codex_remote/android', + ), + AutomationNode( + id: 'action_1', + kind: AutomationNodeKind.runCommand, + commandText: 'echo build', + ), + ], + ), + ); + + expect( + transport.watchPathsById.values.where( + (path) => path == '/home/ege/Documents/Projects/codex_remote/android', + ), + hasLength(1), + ); + + await controller.connect(); + + expect( + transport.watchPathsById.values.where( + (path) => path == '/home/ege/Documents/Projects/codex_remote/android', + ), + hasLength(1), + ); + expect( + controller.entries.any( + (entry) => entry.body.contains('Already watching path'), + ), + isFalse, + ); + }, + ); + test( 'turn completed automation runs its command after a completed turn', () async { @@ -1929,31 +2157,34 @@ void main() { }, ); - test('controller reconnects after resume when transport drops unexpectedly', () async { - final transport = _FakeTransport(); - final controller = AppController.testing(transport: transport); - await controller.saveSettings( - controller.settings.copyWith( - connectionMode: ConnectionMode.relay, - relayUrl: 'https://relay.example.com', - ), - ); + test( + 'controller reconnects after resume when transport drops unexpectedly', + () async { + final transport = _FakeTransport(); + final controller = AppController.testing(transport: transport); + await controller.saveSettings( + controller.settings.copyWith( + connectionMode: ConnectionMode.relay, + relayUrl: 'https://relay.example.com', + ), + ); - await controller.sendPrompt('first'); - expect(controller.status, ConnectionStatus.ready); - expect(transport.connectCount, 1); + await controller.sendPrompt('first'); + expect(controller.status, ConnectionStatus.ready); + expect(transport.connectCount, 1); - transport.simulateUnexpectedDisconnect(); - await Future.delayed(const Duration(milliseconds: 10)); - expect(controller.status, ConnectionStatus.error); + transport.simulateUnexpectedDisconnect(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(controller.status, ConnectionStatus.error); - controller.didChangeAppLifecycleState(AppLifecycleState.paused); - controller.didChangeAppLifecycleState(AppLifecycleState.resumed); - await Future.delayed(const Duration(milliseconds: 20)); + controller.didChangeAppLifecycleState(AppLifecycleState.paused); + controller.didChangeAppLifecycleState(AppLifecycleState.resumed); + await Future.delayed(const Duration(milliseconds: 20)); - expect(transport.connectCount, 2); - expect(controller.status, ConnectionStatus.ready); - }); + expect(transport.connectCount, 2); + expect(controller.status, ConnectionStatus.ready); + }, + ); } class _FakeTransport implements AppTransport { @@ -1991,8 +2222,11 @@ class _FakeTransport implements AppTransport { bool? lastDownloadDisableTimeout; bool? lastCommandUsesTty; bool? lastCommandStreamsStdin; + bool? lastCommandDisableTimeout; + int? lastCommandTimeoutMs; Uri? lastConnectedUri; final List unsubscribedThreadIds = []; + final List removedPaths = []; int _watchCounter = 0; final Map watchPathsById = {}; Completer? delayNextTurnStart; @@ -2002,6 +2236,8 @@ class _FakeTransport implements AppTransport { bool failOnDuplicateWatchPaths = false; bool failHomeDirectoryRead = false; List> threadListData = >[]; + Completer? delayNextConnect; + Completer? delayNextThreadList; @override Stream get messages => _controller.stream; @@ -2011,6 +2247,11 @@ class _FakeTransport implements AppTransport { @override Future connect(AppSettings settings) async { + if (delayNextConnect != null) { + final gate = delayNextConnect!; + delayNextConnect = null; + await gate.future; + } _connected = true; connectCount += 1; lastConnectedUri = Uri.parse( @@ -2060,6 +2301,18 @@ class _FakeTransport implements AppTransport { return; } if (method == 'thread/list') { + if (delayNextThreadList != null) { + final gate = delayNextThreadList!; + delayNextThreadList = null; + unawaited(() async { + await gate.future; + _respond(id as int, { + 'data': threadListData, + 'nextCursor': null, + }); + }()); + return; + } _respond(id as int, { 'data': threadListData, 'nextCursor': null, @@ -2185,6 +2438,8 @@ class _FakeTransport implements AppTransport { } lastCommandUsesTty = params['tty'] == true; lastCommandStreamsStdin = params['streamStdin'] == true; + lastCommandDisableTimeout = params['disableTimeout'] == true; + lastCommandTimeoutMs = params['timeoutMs'] as int?; if (command.length >= 3 && command.first == '/bin/bash' && command[2] == r'wc -c < "$1"') { @@ -2379,6 +2634,15 @@ class _FakeTransport implements AppTransport { _respond(id as int, {}); return; } + if (method == 'fs/remove') { + final params = decoded['params'] as Map; + final path = params['path']?.toString() ?? ''; + removedPaths.add(path); + _fileBytesByPath.remove(path); + _modifiedAtByPath.remove(path); + _respond(id as int, {}); + return; + } if (method == 'account/rateLimits/read') { _respond(id as int, { 'rateLimits': {