// FaiSettingsDialog — modal for changing the hub endpoint. // Reads current values from HubService, persists on save via // HubService.reconnect. import 'package:fai_client_sdk/fai_client_sdk.dart'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/registry_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'fai_error_box.dart'; import 'fai_pill.dart'; import 'fai_system_ai_editor.dart'; import 'theme_picker_grid.dart'; class FaiSettingsDialog extends StatefulWidget { const FaiSettingsDialog({super.key}); /// Convenience launcher used from the sidebar gear icon. static Future show(BuildContext context) async { final ok = await showDialog( context: context, builder: (_) => const FaiSettingsDialog(), ); return ok ?? false; } @override State createState() => _FaiSettingsDialogState(); } class _FaiSettingsDialogState extends State { late final TextEditingController _host; late final TextEditingController _port; bool _secure = false; bool _saving = false; String? _error; String? _channelToast; ChannelStatusSnapshot? _channels; SystemAiStatus? _aiStatus; List? _mcpClients; List? _n8nEndpoints; /// Length of the configured registry token, or null when the /// `~/.fai/registry-token` file is missing / empty. Reloaded /// after save / clear. int? _registryTokenChars; /// Length of the configured hub gRPC bearer token, or null /// when `~/.fai/hub-auth-token` is missing / empty. Reloaded /// after save / clear; UI only ever sees the trimmed length. int? _hubAuthTokenChars; @override void initState() { super.initState(); final ep = HubService.instance.currentEndpoint; _host = TextEditingController(text: ep.host); _port = TextEditingController(text: ep.port.toString()); _secure = ep.secure; _loadChannels(); _loadAiStatus(); _loadMcpClients(); _loadN8nEndpoints(); _loadRegistryToken(); _loadHubAuthToken(); } Future _loadHubAuthToken() async { try { final n = await HubAuthToken.charCount(); if (!mounted) return; setState(() => _hubAuthTokenChars = n); } catch (_) { /* fail-quiet */ } } Future _saveHubAuthToken(String token) async { try { await HubAuthToken.write(token); await HubService.instance.reloadAuthToken(); await _loadHubAuthToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast))); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'auth.hub-token.save', e); } } Future _clearHubAuthToken() async { try { await HubAuthToken.clear(); await HubService.instance.reloadAuthToken(); await _loadHubAuthToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast))); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'auth.hub-token.clear', e); } } Future _loadRegistryToken() async { try { final n = await RegistryToken.charCount(); if (!mounted) return; setState(() => _registryTokenChars = n); } catch (_) { /* fail-quiet */ } } Future _saveRegistryToken(String token) async { try { await RegistryToken.write(token); await _loadRegistryToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast))); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'auth.registry-token.save', e); } } Future _clearRegistryToken() async { try { await RegistryToken.delete(); await _loadRegistryToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast))); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'auth.registry-token.clear', e); } } Future _loadN8nEndpoints() async { try { final list = await HubService.instance.listN8nEndpoints(); if (!mounted) return; setState(() => _n8nEndpoints = list); } catch (_) { /* fail-quiet */ } } Future _addN8nEndpoint(N8nEndpointDraft draft) async { try { final list = await HubService.instance.addN8nEndpoint( name: draft.name, baseUrl: draft.baseUrl, apiKeyEnv: draft.apiKeyEnv, description: draft.description, ); if (!mounted) return; setState(() => _n8nEndpoints = list); final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name)))); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'settings.n8n.add', e); } } Future _removeN8nEndpoint(String name) async { try { final list = await HubService.instance.removeN8nEndpoint(name); if (!mounted) return; setState(() => _n8nEndpoints = list); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'settings.n8n.remove', e); } } Future _refreshN8nEndpoints() async { try { final list = await HubService.instance.refreshN8nEndpoints(); if (!mounted) return; setState(() => _n8nEndpoints = list); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'settings.n8n.refresh', e); } } Future _loadMcpClients() async { try { final list = await HubService.instance.listMcpClients(); if (!mounted) return; setState(() => _mcpClients = list); } catch (_) { // Same fail-quiet rule as channels. } } Future _addMcpClient(McpClientDraft draft) async { try { final list = await HubService.instance.addMcpClient( name: draft.name, endpoint: draft.endpoint, apiKeyEnv: draft.apiKeyEnv, description: draft.description, ); if (!mounted) return; setState(() => _mcpClients = list); final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name)))); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'settings.mcp.add', e); } } Future _removeMcpClient(String name) async { try { final list = await HubService.instance.removeMcpClient(name); if (!mounted) return; setState(() => _mcpClients = list); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'settings.mcp.remove', e); } } Future _refreshMcpClients() async { try { final list = await HubService.instance.refreshMcpClients(); if (!mounted) return; setState(() => _mcpClients = list); } catch (e) { if (!mounted) return; showFaiErrorSnack(context, 'settings.mcp.refresh', e); } } Future _loadChannels() async { try { final snap = await HubService.instance.channelStatus(); if (!mounted) return; setState(() => _channels = snap); } catch (_) { // Silent: channels block stays hidden when the hub is // unreachable (the endpoint section is the operator's // path back to a working connection). } } Future _loadAiStatus() async { try { final s = await HubService.instance.systemAiStatus(); if (!mounted) return; setState(() => _aiStatus = s); } catch (_) { // Same fail-quiet rule as channels. } } Future _switchChannel(String name) async { setState(() { _saving = true; _channelToast = null; }); final r = await SystemActions.faiChannelSwitch(name); if (!mounted) return; setState(() { _saving = false; _channelToast = r.ok ? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}' : 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}'; }); if (r.ok) await _loadChannels(); } Future _runDaemon( String label, Future<({bool ok, String stdout, String stderr})> Function() action, ) async { setState(() { _saving = true; _channelToast = null; }); final r = await action(); if (!mounted) return; setState(() { _saving = false; _channelToast = r.ok ? 'OK · $label\n${r.stdout.trim()}' : 'Failed · $label\n${(r.stderr.isEmpty ? r.stdout : r.stderr).trim()}'; }); } Future _connectToChannel(ChannelInfo ch) async { setState(() { _host.text = '127.0.0.1'; _port.text = ch.port.toString(); _secure = false; }); await _save(); } @override void dispose() { _host.dispose(); _port.dispose(); super.dispose(); } Future _save() async { setState(() { _saving = true; _error = null; }); final port = int.tryParse(_port.text.trim()); if (port == null || port <= 0 || port > 65535) { final l = AppLocalizations.of(context)!; setState(() { _saving = false; _error = l.settingsPortError; }); return; } final endpoint = HubEndpoint( host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(), port: port, secure: _secure, ); try { await HubService.instance.reconnect(endpoint); if (!mounted) return; Navigator.pop(context, true); } catch (e) { setState(() { _saving = false; _error = e.toString(); }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.settingsTitle), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), ), contentPadding: const EdgeInsets.symmetric( horizontal: FaiSpace.xl, vertical: FaiSpace.lg, ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520, maxHeight: 640), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.settingsHubEndpointHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), TextField( controller: _host, decoration: InputDecoration( labelText: l.settingsHost, border: const OutlineInputBorder(), isDense: true, ), autofocus: true, ), const SizedBox(height: FaiSpace.md), Row( children: [ Expanded( flex: 2, child: TextField( controller: _port, decoration: InputDecoration( labelText: l.settingsPort, border: const OutlineInputBorder(), isDense: true, ), keyboardType: TextInputType.number, ), ), const SizedBox(width: FaiSpace.md), Expanded( flex: 3, child: SwitchListTile( contentPadding: EdgeInsets.zero, dense: true, title: Text(l.settingsTls), subtitle: Text( l.settingsTlsSubtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), value: _secure, onChanged: (v) => setState(() => _secure = v), ), ), ], ), if (_error != null) ...[ const SizedBox(height: FaiSpace.md), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], const SizedBox(height: FaiSpace.md), Container( padding: const EdgeInsets.all(FaiSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), ), child: Row( children: [ Icon( Icons.link, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( _previewUrl(), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ], ), ), if (_channels != null) ...[ const SizedBox(height: FaiSpace.lg), Text( l.channelsHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( l.channelsBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), for (final ch in _channels!.channels) _ChannelRow( channel: ch, active: ch.name == _channels!.active, onConnect: _saving ? null : () => _connectToChannel(ch), onSwitch: _saving ? null : () => _switchChannel(ch.name), onEnableAutostart: _saving ? null : () => _runDaemon( 'enable autostart', () => SystemActions.faiDaemonEnable(ch.name), ), onDisableAutostart: _saving ? null : () => _runDaemon( 'disable autostart', () => SystemActions.faiDaemonDisable(ch.name), ), ), if (_channelToast != null) ...[ const SizedBox(height: FaiSpace.sm), FaiErrorBox(text: _channelToast!, maxHeight: 200), ], ], if (_aiStatus != null) ...[ const SizedBox(height: FaiSpace.lg), _SystemAiPanel( status: _aiStatus!, onEdit: () async { final updated = await FaiSystemAiEditor.show( context, _aiStatus!, ); if (updated != null && mounted) { setState(() => _aiStatus = updated); } }, ), ], if (_mcpClients != null) ...[ const SizedBox(height: FaiSpace.lg), _McpClientsPanel( clients: _mcpClients!, onAdd: _addMcpClient, onRemove: _removeMcpClient, onRefresh: _refreshMcpClients, ), ], if (_n8nEndpoints != null) ...[ const SizedBox(height: FaiSpace.lg), _N8nEndpointsPanel( endpoints: _n8nEndpoints!, onAdd: _addN8nEndpoint, onRemove: _removeN8nEndpoint, onRefresh: _refreshN8nEndpoints, ), ], const SizedBox(height: FaiSpace.lg), _RegistryCredentialsPanel( configuredChars: _registryTokenChars, onSave: _saveRegistryToken, onClear: _clearRegistryToken, ), const SizedBox(height: FaiSpace.lg), _HubAuthTokenPanel( configuredChars: _hubAuthTokenChars, onSave: _saveHubAuthToken, onClear: _clearHubAuthToken, ), const SizedBox(height: FaiSpace.lg), const _DefaultScopePanel(), const SizedBox(height: FaiSpace.lg), const _ThemePluginPanel(), const SizedBox(height: FaiSpace.lg), _MaintenancePanel( onResetDone: () async { // Daemon just restarted under the same channel + // port. Bouncing the gRPC connection picks up the // fresh state without the operator having to // close+reopen Settings. await HubService.instance.reconnect( HubService.instance.currentEndpoint, ); if (!mounted) return; await _loadChannels(); await _loadAiStatus(); await _loadMcpClients(); await _loadN8nEndpoints(); await _loadRegistryToken(); }, ), ], ), ), ), actions: [ TextButton( onPressed: _saving ? null : () => Navigator.pop(context, false), child: Text(l.buttonCancel), ), FilledButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(l.settingsSaveAndConnect), ), ], ); } String _previewUrl() { final scheme = _secure ? 'https' : 'http'; final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(); final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim(); return '$scheme://$host:$port'; } } class _ChannelRow extends StatelessWidget { final ChannelInfo channel; final bool active; final VoidCallback? onConnect; /// Switches the active channel pointer (`fai channel switch`). /// Distinct from [onConnect], which only re-points Studio's /// gRPC wire at a different running daemon. final VoidCallback? onSwitch; final VoidCallback? onEnableAutostart; final VoidCallback? onDisableAutostart; const _ChannelRow({ required this.channel, required this.active, required this.onConnect, required this.onSwitch, required this.onEnableAutostart, required this.onDisableAutostart, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon( channel.running ? Icons.circle : Icons.circle_outlined, size: 10, color: channel.running ? FaiColors.success : theme.colorScheme.outline, ), const SizedBox(width: FaiSpace.sm), SizedBox( width: 88, child: Text( channel.name, style: FaiTheme.mono( size: 11, weight: active ? FontWeight.w600 : FontWeight.w400, color: theme.colorScheme.onSurface, ), ), ), SizedBox( width: 64, child: Text( ':${channel.port}', style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( channel.running ? l.channelsRunning : l.channelsStopped, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), if (active) Padding( padding: const EdgeInsets.only(right: FaiSpace.sm), child: FaiPill( label: l.channelsActive, tone: FaiPillTone.success, ), ), PopupMenuButton( tooltip: l.channelsActionsTooltip, icon: const Icon(Icons.more_vert, size: 18), onSelected: (v) { switch (v) { case 'connect': onConnect?.call(); break; case 'switch': onSwitch?.call(); break; case 'enable': onEnableAutostart?.call(); break; case 'disable': onDisableAutostart?.call(); break; } }, itemBuilder: (_) => [ PopupMenuItem( value: 'connect', enabled: channel.running && onConnect != null, child: _MenuRow(icon: Icons.link, text: l.channelsConnect), ), PopupMenuItem( value: 'switch', enabled: !active && onSwitch != null, child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch), ), const PopupMenuDivider(), PopupMenuItem( value: 'enable', enabled: onEnableAutostart != null, child: _MenuRow( icon: Icons.play_circle_outline, text: l.channelsEnableAutostart, ), ), PopupMenuItem( value: 'disable', enabled: onDisableAutostart != null, child: _MenuRow( icon: Icons.pause_circle_outline, text: l.channelsDisableAutostart, ), ), ], ), ], ), ); } } class _MenuRow extends StatelessWidget { final IconData icon; final String text; const _MenuRow({required this.icon, required this.text}); @override Widget build(BuildContext context) { return Row( children: [ Icon(icon, size: 14), const SizedBox(width: FaiSpace.sm), Text(text), ], ); } } class _SystemAiPanel extends StatelessWidget { final SystemAiStatus status; final VoidCallback onEdit; const _SystemAiPanel({required this.status, required this.onEdit}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( l.systemAiHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: status.enabled ? l.systemAiEnabled : l.systemAiOff, tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral, ), if (status.enabled) ...[ const SizedBox(width: FaiSpace.xs), FaiPill( label: status.privacyMode, tone: status.privacyMode == 'full' ? FaiPillTone.warning : FaiPillTone.neutral, ), ], const Spacer(), TextButton.icon( onPressed: onEdit, icon: const Icon(Icons.edit_outlined, size: 14), label: Text( status.enabled ? l.systemAiEdit : l.systemAiConfigure, ), ), ], ), const SizedBox(height: 4), if (status.enabled) ...[ _StatRow(label: 'provider', value: status.provider), _StatRow(label: 'endpoint', value: status.endpoint, mono: true), _StatRow(label: 'model', value: status.model, mono: true), if (status.apiKeyEnv.isNotEmpty) _StatRow( label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true, ), ] else ...[ Padding( padding: const EdgeInsets.only(top: 2), child: Text( l.systemAiOffBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ], ); } } class _StatRow extends StatelessWidget { final String label; final String value; final bool mono; const _StatRow({required this.label, required this.value, this.mono = false}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 96, child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( value.isEmpty ? '—' : value, style: mono ? FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface) : theme.textTheme.bodySmall, ), ), ], ), ); } } /// Draft passed back from the add-server dialog. Kept tiny so /// the panel can stay stateless. class McpClientDraft { final String name; final String endpoint; final String apiKeyEnv; final String description; const McpClientDraft({ required this.name, required this.endpoint, required this.apiKeyEnv, required this.description, }); } /// MCP-clients panel — shows every configured server with its /// last-discovery health, plus Add / Remove / Refresh actions. /// Configure a server here, the store gains synthetic /// capabilities for every advertised tool. Mirrors `fai mcp …` /// on the CLI. class _McpClientsPanel extends StatelessWidget { final List clients; final ValueChanged onAdd; final ValueChanged onRemove; final VoidCallback onRefresh; const _McpClientsPanel({ required this.clients, required this.onAdd, required this.onRemove, required this.onRefresh, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( l.mcpHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: l.mcpServersCount(clients.length), tone: FaiPillTone.neutral, ), const Spacer(), IconButton( icon: const Icon(Icons.refresh, size: 16), tooltip: l.mcpRediscoverTooltip, visualDensity: VisualDensity.compact, onPressed: onRefresh, ), IconButton( icon: const Icon(Icons.add, size: 16), tooltip: l.mcpAddTooltip, visualDensity: VisualDensity.compact, onPressed: () async { final draft = await _AddMcpClientDialog.show(context); if (draft != null) onAdd(draft); }, ), ], ), const SizedBox(height: 2), Text( l.mcpHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), if (clients.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), child: Text( l.mcpEmpty, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) else for (final c in clients) _McpClientRow(client: c, onRemove: () => onRemove(c.name)), ], ); } } class _McpClientRow extends StatelessWidget { final McpClientInfo client; final VoidCallback onRemove; const _McpClientRow({required this.client, required this.onRemove}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final ok = client.healthy; final dotColor = client.errorKind.isEmpty ? (ok ? FaiColors.success : FaiColors.muted) : theme.colorScheme.error; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 6), child: Icon(Icons.circle, size: 8, color: dotColor), ), const SizedBox(width: FaiSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( client.name, style: FaiTheme.mono( size: 12, weight: FontWeight.w600, color: theme.colorScheme.onSurface, ), ), const SizedBox(width: FaiSpace.sm), if (client.toolCount > 0) FaiPill( label: l.mcpToolsCount(client.toolCount), tone: FaiPillTone.success, ) else if (client.errorKind.isNotEmpty) FaiPill( label: client.errorKind, tone: FaiPillTone.danger, ), ], ), Text( client.endpoint, style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), if (client.message.isNotEmpty) Text( client.message, style: theme.textTheme.bodySmall?.copyWith( color: client.errorKind.isEmpty ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.error, ), ), ], ), ), IconButton( icon: const Icon(Icons.delete_outline, size: 14), tooltip: l.mcpRemoveTooltip, visualDensity: VisualDensity.compact, onPressed: onRemove, ), ], ), ); } } class _AddMcpClientDialog extends StatefulWidget { const _AddMcpClientDialog(); static Future show(BuildContext context) { return showDialog( context: context, builder: (_) => const _AddMcpClientDialog(), ); } @override State<_AddMcpClientDialog> createState() => _AddMcpClientDialogState(); } class _AddMcpClientDialogState extends State<_AddMcpClientDialog> { final _name = TextEditingController(); final _endpoint = TextEditingController(); final _apiKey = TextEditingController(); final _desc = TextEditingController(); /// Pick a suggestion → fill the form. Operators still hit /// "Add + discover" so nothing happens unprompted. void _applySuggestion(_McpSuggestion s) { final l = AppLocalizations.of(context)!; setState(() { _name.text = s.name; _endpoint.text = s.endpoint; _apiKey.text = s.apiKeyEnv; _desc.text = s.resolveDescription(l); }); } @override void dispose() { _name.dispose(); _endpoint.dispose(); _apiKey.dispose(); _desc.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.addMcpTitle), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.addMcpIntro, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), // Curated suggestions from the official Anthropic // MCP servers + a few community staples. Click a // chip to pre-fill the form. The operator still // confirms via "Add + discover" — no unprompted // subprocess spawn. Text( l.addMcpSuggestionsLabel, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.5, ), ), const SizedBox(height: 6), Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ for (final s in _kMcpSuggestions) ActionChip( avatar: Icon(s.icon, size: 14), label: Text(s.name), onPressed: () => _applySuggestion(s), ), ], ), const Divider(height: FaiSpace.xl), TextField( controller: _name, autofocus: true, decoration: InputDecoration( labelText: l.addMcpNameLabel, hintText: l.addMcpNameHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _endpoint, decoration: InputDecoration( labelText: l.addMcpEndpointLabel, hintText: l.addMcpEndpointHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _apiKey, decoration: InputDecoration( labelText: l.addMcpApiKeyLabel, hintText: l.addMcpApiKeyHint, prefixText: '\$', border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _desc, decoration: InputDecoration( labelText: l.addMcpNotesLabel, border: const OutlineInputBorder(), isDense: true, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: Text(l.buttonCancel), ), FilledButton( onPressed: () { if (_name.text.trim().isEmpty || _endpoint.text.trim().isEmpty) { return; } Navigator.pop( context, McpClientDraft( name: _name.text.trim(), endpoint: _endpoint.text.trim(), apiKeyEnv: _apiKey.text.trim(), description: _desc.text.trim(), ), ); }, child: Text(l.addMcpAddButton), ), ], ); } } class N8nEndpointDraft { final String name; final String baseUrl; final String apiKeyEnv; final String description; const N8nEndpointDraft({ required this.name, required this.baseUrl, required this.apiKeyEnv, required this.description, }); } /// n8n-endpoints panel — sister of `_McpClientsPanel`. Shows /// every configured n8n install with last-discovery health /// (workflow count or error_kind), plus Add / Remove / Refresh. class _N8nEndpointsPanel extends StatelessWidget { final List endpoints; final ValueChanged onAdd; final ValueChanged onRemove; final VoidCallback onRefresh; const _N8nEndpointsPanel({ required this.endpoints, required this.onAdd, required this.onRemove, required this.onRefresh, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( l.n8nHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: l.n8nEndpointsCount(endpoints.length), tone: FaiPillTone.neutral, ), const Spacer(), IconButton( icon: const Icon(Icons.refresh, size: 16), tooltip: l.n8nRediscoverTooltip, visualDensity: VisualDensity.compact, onPressed: onRefresh, ), IconButton( icon: const Icon(Icons.add, size: 16), tooltip: l.n8nAddTooltip, visualDensity: VisualDensity.compact, onPressed: () async { final draft = await _AddN8nEndpointDialog.show(context); if (draft != null) onAdd(draft); }, ), ], ), const SizedBox(height: 2), Text( l.n8nHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), if (endpoints.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), child: Text( l.n8nEmpty, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) else for (final e in endpoints) _N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)), ], ); } } class _N8nEndpointRow extends StatelessWidget { final N8nEndpointInfo endpoint; final VoidCallback onRemove; const _N8nEndpointRow({required this.endpoint, required this.onRemove}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final ok = endpoint.healthy; final dotColor = endpoint.errorKind.isEmpty ? (ok ? FaiColors.success : FaiColors.muted) : theme.colorScheme.error; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 6), child: Icon(Icons.circle, size: 8, color: dotColor), ), const SizedBox(width: FaiSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( endpoint.name, style: FaiTheme.mono( size: 12, weight: FontWeight.w600, color: theme.colorScheme.onSurface, ), ), const SizedBox(width: FaiSpace.sm), if (endpoint.workflowCount > 0) FaiPill( label: l.n8nWorkflowsCount(endpoint.workflowCount), tone: FaiPillTone.success, ) else if (endpoint.errorKind.isNotEmpty) FaiPill( label: endpoint.errorKind, tone: FaiPillTone.danger, ), ], ), Text( endpoint.baseUrl, style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), if (endpoint.message.isNotEmpty) Text( endpoint.message, style: theme.textTheme.bodySmall?.copyWith( color: endpoint.errorKind.isEmpty ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.error, ), ), ], ), ), IconButton( icon: const Icon(Icons.delete_outline, size: 14), tooltip: l.n8nRemoveTooltip, visualDensity: VisualDensity.compact, onPressed: onRemove, ), ], ), ); } } class _AddN8nEndpointDialog extends StatefulWidget { const _AddN8nEndpointDialog(); static Future show(BuildContext context) { return showDialog( context: context, builder: (_) => const _AddN8nEndpointDialog(), ); } @override State<_AddN8nEndpointDialog> createState() => _AddN8nEndpointDialogState(); } class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { final _name = TextEditingController(); final _baseUrl = TextEditingController(); final _apiKey = TextEditingController(); final _desc = TextEditingController(); @override void dispose() { _name.dispose(); _baseUrl.dispose(); _apiKey.dispose(); _desc.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.addN8nTitle), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.addN8nIntro, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), TextField( controller: _name, autofocus: true, decoration: InputDecoration( labelText: l.addN8nNameLabel, hintText: l.addN8nNameHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _baseUrl, decoration: InputDecoration( labelText: l.addN8nBaseUrlLabel, hintText: l.addN8nBaseUrlHint, border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _apiKey, decoration: InputDecoration( labelText: l.addN8nApiKeyLabel, hintText: l.addN8nApiKeyHint, prefixText: '\$', border: const OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _desc, decoration: InputDecoration( labelText: l.addN8nNotesLabel, border: const OutlineInputBorder(), isDense: true, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: Text(l.buttonCancel), ), FilledButton( onPressed: () { if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return; Navigator.pop( context, N8nEndpointDraft( name: _name.text.trim(), baseUrl: _baseUrl.text.trim(), apiKeyEnv: _apiKey.text.trim(), description: _desc.text.trim(), ), ); }, child: Text(l.addN8nAddButton), ), ], ); } } /// One curated MCP-server suggestion — the operator clicks /// the chip to pre-fill the add-dialog. Strictly informational /// metadata; nothing is auto-spawned. Trust model: /// referencing public projects with their canonical install /// command is the same legal / security shape as a package /// Theme-plugin picker. Lists installed `studio.theme.*` /// capabilities (via list_capabilities) and lets the operator /// pick one or fall back to Studio's built-in palette. The /// choice is persisted in SharedPreferences and applied at /// the MaterialApp level via `StudioApp.of(context).setThemePlugin`. /// /// When no theme plugin is installed the panel renders a /// short hint pointing at the Store rather than a useless /// empty dropdown. class _ThemePluginPanel extends StatelessWidget { const _ThemePluginPanel(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.themePluginHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 8), // Rich grid of theme tiles. Each shows live swatches // from the plugin's own palette so the operator can // preview without applying. The "Custom" tile opens // a seed-colour dialog. const ThemePickerGrid(), ], ); } } /// Hub maintenance panel — currently one action: reset operator /// state via `fai reset --yes`. Lives at the bottom of Settings /// so the everyday config controls aren't crowded by a destructive /// button. The reset itself is gated by an interactive confirm /// dialog before the CLI is invoked. class _MaintenancePanel extends StatefulWidget { /// Called after a successful reset so the parent Settings dialog /// can bounce its gRPC channel and reload its panels. final Future Function() onResetDone; const _MaintenancePanel({required this.onResetDone}); @override State<_MaintenancePanel> createState() => _MaintenancePanelState(); } class _MaintenancePanelState extends State<_MaintenancePanel> { bool _keepModules = false; bool _keepData = false; bool _resetting = false; String? _resultText; bool _resultOk = false; Future _confirmAndReset() async { final l = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.maintenanceResetConfirmTitle), content: Text(l.maintenanceResetConfirmBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(l.buttonCancel), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, ), onPressed: () => Navigator.pop(ctx, true), child: Text(l.maintenanceResetConfirmButton), ), ], ), ); if (confirmed != true || !mounted) return; setState(() { _resetting = true; _resultText = null; }); final r = await SystemActions.faiReset( keepModules: _keepModules, keepData: _keepData, ); if (!mounted) return; setState(() { _resetting = false; _resultOk = r.ok; _resultText = r.ok ? r.stdout.trim() : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); }); if (r.ok) await widget.onResetDone(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.maintenanceHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( l.maintenanceResetBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), CheckboxListTile( contentPadding: EdgeInsets.zero, dense: true, controlAffinity: ListTileControlAffinity.leading, value: _keepModules, onChanged: _resetting ? null : (v) => setState(() => _keepModules = v ?? false), title: Text(l.maintenanceKeepModulesTitle), subtitle: Text( l.maintenanceKeepModulesSubtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), CheckboxListTile( contentPadding: EdgeInsets.zero, dense: true, controlAffinity: ListTileControlAffinity.leading, value: _keepData, onChanged: _resetting ? null : (v) => setState(() => _keepData = v ?? false), title: Text(l.maintenanceKeepDataTitle), subtitle: Text( l.maintenanceKeepDataSubtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), const SizedBox(height: FaiSpace.sm), Row( children: [ FilledButton.icon( icon: _resetting ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.restart_alt, size: 16), label: Text( _resetting ? l.maintenanceResetInProgress : l.maintenanceResetButton, ), style: FilledButton.styleFrom( backgroundColor: theme.colorScheme.errorContainer, foregroundColor: theme.colorScheme.onErrorContainer, ), onPressed: _resetting ? null : _confirmAndReset, ), ], ), if (_resultText != null) ...[ const SizedBox(height: FaiSpace.sm), FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240), ], ], ); } } /// Credentials panel for the operator's registry auth token. /// The hub reads the same token via `~/.fai/registry-token` /// (or the `FAI_REGISTRY_TOKEN` env var, which still wins) /// when downloading `.fai` bundles from a registry behind a /// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true, /// GitHub-private releases, etc. /// /// The token itself is never round-tripped back into Studio /// after save: `configuredChars` is the trimmed length, used /// only as a "Configured (40 chars)" status display so the /// operator knows something landed without seeing the secret. class _RegistryCredentialsPanel extends StatefulWidget { /// Trimmed length of the persisted token, or null when none /// is configured. final int? configuredChars; final ValueChanged onSave; final Future Function() onClear; const _RegistryCredentialsPanel({ required this.configuredChars, required this.onSave, required this.onClear, }); @override State<_RegistryCredentialsPanel> createState() => _RegistryCredentialsPanelState(); } class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> { final _controller = TextEditingController(); bool _obscured = true; @override void initState() { super.initState(); _controller.addListener(() { if (mounted) setState(() {}); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final isConfigured = widget.configuredChars != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.registryCredentialsHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( l.registryCredentialsBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), Row( children: [ Icon( isConfigured ? Icons.check_circle : Icons.radio_button_unchecked, size: 14, color: isConfigured ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.xs), Text( isConfigured ? l.registryTokenStatusConfigured(widget.configuredChars!) : l.registryTokenStatusNotSet, style: theme.textTheme.bodySmall?.copyWith( color: isConfigured ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, fontWeight: isConfigured ? FontWeight.w600 : null, ), ), const Spacer(), if (isConfigured) TextButton.icon( icon: const Icon(Icons.delete_outline, size: 14), onPressed: () async { await widget.onClear(); _controller.clear(); }, label: Text(l.registryTokenClearButton), ), ], ), const SizedBox(height: FaiSpace.sm), Row( children: [ Expanded( child: TextField( controller: _controller, obscureText: _obscured, decoration: InputDecoration( labelText: l.registryTokenFieldLabel, hintText: l.registryTokenFieldHint, border: const OutlineInputBorder(), isDense: true, suffixIcon: IconButton( icon: Icon( _obscured ? Icons.visibility : Icons.visibility_off, size: 16, ), onPressed: () => setState(() => _obscured = !_obscured), ), ), ), ), const SizedBox(width: FaiSpace.sm), FilledButton.icon( icon: const Icon(Icons.save_outlined, size: 14), label: Text(l.registryTokenSaveButton), onPressed: _controller.text.trim().isEmpty ? null : () { widget.onSave(_controller.text); _controller.clear(); }, ), ], ), const SizedBox(height: 4), Text( l.registryTokenStorageHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, ), ), ], ); } } /// Operator panel for the hub's gRPC bearer token. When the /// hub has `auth.tokens:` configured, Studio must present /// `Authorization: Bearer ` on every call. The token /// is stored at `~/.fai/hub-auth-token` (mode 0600) and read /// at startup by [HubService]; this panel just lets the /// operator paste / clear without leaving the GUI. /// /// Same hygiene as the registry-credentials panel: the token /// itself never round-trips back into Studio after save; /// `configuredChars` is just the trimmed length for the /// status display. class _HubAuthTokenPanel extends StatefulWidget { final int? configuredChars; final ValueChanged onSave; final Future Function() onClear; const _HubAuthTokenPanel({ required this.configuredChars, required this.onSave, required this.onClear, }); @override State<_HubAuthTokenPanel> createState() => _HubAuthTokenPanelState(); } class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> { final _controller = TextEditingController(); bool _obscured = true; @override void initState() { super.initState(); _controller.addListener(() { if (mounted) setState(() {}); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final isConfigured = widget.configuredChars != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.hubAuthTokenHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( l.hubAuthTokenBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), Row( children: [ Icon( isConfigured ? Icons.check_circle : Icons.radio_button_unchecked, size: 14, color: isConfigured ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.xs), Text( isConfigured ? l.hubAuthTokenStatusConfigured(widget.configuredChars!) : l.hubAuthTokenStatusNotSet, style: theme.textTheme.bodySmall?.copyWith( color: isConfigured ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, fontWeight: isConfigured ? FontWeight.w600 : null, ), ), const Spacer(), if (isConfigured) TextButton.icon( icon: const Icon(Icons.delete_outline, size: 14), onPressed: () async { await widget.onClear(); _controller.clear(); }, label: Text(l.hubAuthTokenClearButton), ), ], ), const SizedBox(height: FaiSpace.sm), Row( children: [ Expanded( child: TextField( controller: _controller, obscureText: _obscured, decoration: InputDecoration( labelText: l.hubAuthTokenFieldLabel, hintText: l.hubAuthTokenFieldHint, border: const OutlineInputBorder(), isDense: true, suffixIcon: IconButton( icon: Icon( _obscured ? Icons.visibility : Icons.visibility_off, size: 16, ), onPressed: () => setState(() => _obscured = !_obscured), ), ), ), ), const SizedBox(width: FaiSpace.sm), FilledButton.icon( icon: const Icon(Icons.save_outlined, size: 14), label: Text(l.hubAuthTokenSaveButton), onPressed: _controller.text.trim().isEmpty ? null : () { widget.onSave(_controller.text); _controller.clear(); }, ), ], ), const SizedBox(height: 4), Text( l.hubAuthTokenStorageHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, ), ), ], ); } } class _McpSuggestion { final String name; final IconData icon; final String endpoint; final String apiKeyEnv; /// Localizable description. Use [resolveDescription] to fetch /// the actual text in the active locale — the const list /// can't hold a closure that takes [AppLocalizations], and /// hardcoding English here is what got us into the /// untranslated-helper-text bug. String resolveDescription(AppLocalizations l) => _suggestionDescription(l, name); const _McpSuggestion({ required this.name, required this.icon, required this.endpoint, required this.apiKeyEnv, }); } /// Localizable lookup of an MCP-suggestion description. Keyed /// on the same suggestion `name` field as the list below. New /// suggestions need a matching .arb entry; the default-case /// returns an empty string so an unconfigured suggestion fails /// silently instead of leaking a key into the UI. String _suggestionDescription(AppLocalizations l, String name) { switch (name) { case 'deepwiki': return l.mcpSuggestionDeepwikiDesc; case 'semgrep': return l.mcpSuggestionSemgrepDesc; case 'filesystem': return l.mcpSuggestionFilesystemDesc; case 'fetch': return l.mcpSuggestionFetchDesc; case 'github': return l.mcpSuggestionGithubDesc; case 'puppeteer': return l.mcpSuggestionPuppeteerDesc; case 'postgres': return l.mcpSuggestionPostgresDesc; case 'sqlite': return l.mcpSuggestionSqliteDesc; case 'brave-search': return l.mcpSuggestionBraveSearchDesc; case 'memory': return l.mcpSuggestionMemoryDesc; case 'time': return l.mcpSuggestionTimeDesc; default: return ''; } } const _kMcpSuggestions = <_McpSuggestion>[ // ── HTTPS / streamable-HTTP servers (no Node, no API key) ── // Descriptions are resolved via [_McpSuggestion.resolveDescription] // against the active locale — see `mcpSuggestion*Desc` keys in // app_en.arb / app_de.arb. Adding a suggestion here means // adding a matching key in both .arb files plus a case to // `_suggestionDescription` above. _McpSuggestion( name: 'deepwiki', icon: Icons.menu_book_outlined, endpoint: 'https://mcp.deepwiki.com/mcp', apiKeyEnv: '', ), _McpSuggestion( name: 'semgrep', icon: Icons.security, endpoint: 'https://mcp.semgrep.ai/mcp', apiKeyEnv: '', ), // ── stdio servers (require Node + npx) ─────────────────── _McpSuggestion( name: 'filesystem', icon: Icons.folder_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp', apiKeyEnv: '', ), _McpSuggestion( name: 'fetch', icon: Icons.cloud_download_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch', apiKeyEnv: '', ), _McpSuggestion( name: 'github', icon: Icons.code, endpoint: 'stdio://npx -y @modelcontextprotocol/server-github', apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN', ), _McpSuggestion( name: 'puppeteer', icon: Icons.web, endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer', apiKeyEnv: '', ), _McpSuggestion( name: 'postgres', icon: Icons.storage_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db', apiKeyEnv: '', ), _McpSuggestion( name: 'sqlite', icon: Icons.dataset_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite', apiKeyEnv: '', ), _McpSuggestion( name: 'brave-search', icon: Icons.search, endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search', apiKeyEnv: 'BRAVE_API_KEY', ), _McpSuggestion( name: 'memory', icon: Icons.psychology_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory', apiKeyEnv: '', ), _McpSuggestion( name: 'time', icon: Icons.schedule, endpoint: 'stdio://npx -y @modelcontextprotocol/server-time', apiKeyEnv: '', ), ]; /// Operator panel for `default_scope:` — the ordered list of /// publisher segments the bare-form capability resolver consults /// when a flow names a capability without an explicit /// `/` prefix (e.g. `text.extract@^1` resolves to /// `fai/text.extract@^1` when `default_scope: [fai]`). /// /// The hub returns the live list plus a catalog-known shortlist /// (`knownProviders`) — the panel renders the shortlist as /// suggestion chips and lets operators paste arbitrary entries /// for private publisher segments. /// /// The order matters: when more than one segment matches a bare /// capability name, the first match wins. The panel surfaces the /// order with simple up/down buttons so operators can reorder /// without dragging. class _DefaultScopePanel extends StatefulWidget { const _DefaultScopePanel(); @override State<_DefaultScopePanel> createState() => _DefaultScopePanelState(); } class _DefaultScopePanelState extends State<_DefaultScopePanel> { List? _scope; List _knownProviders = const []; bool _loading = true; bool _saving = false; String? _error; final _addController = TextEditingController(); @override void initState() { super.initState(); _load(); } @override void dispose() { _addController.dispose(); super.dispose(); } Future _load() async { try { final r = await HubService.instance.getDefaultScope(); if (!mounted) return; setState(() { _scope = r.scope; _knownProviders = r.knownProviders; _loading = false; _error = null; }); } catch (e) { if (!mounted) return; setState(() { _loading = false; _error = e.toString(); }); } } Future _save(List next) async { setState(() { _saving = true; _error = null; }); try { final r = await HubService.instance.setDefaultScope(next); if (!mounted) return; setState(() { _scope = r.scope; _knownProviders = r.knownProviders; _saving = false; }); final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast))); } catch (e) { if (!mounted) return; setState(() { _saving = false; _error = e.toString(); }); } } void _addEntry(String raw) { final entry = raw.trim(); if (entry.isEmpty) return; final current = List.from(_scope ?? const []); if (current.contains(entry)) return; current.add(entry); _addController.clear(); _save(current); } void _removeAt(int i) { final current = List.from(_scope ?? const []); if (i < 0 || i >= current.length) return; if (current.length == 1) { // Hub rejects empty; surface the constraint inline rather // than letting the RPC fail. setState( () => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError, ); return; } current.removeAt(i); _save(current); } void _moveUp(int i) { if (i <= 0) return; final current = List.from(_scope ?? const []); final item = current.removeAt(i); current.insert(i - 1, item); _save(current); } void _moveDown(int i) { final current = List.from(_scope ?? const []); if (i >= current.length - 1) return; final item = current.removeAt(i); current.insert(i + 1, item); _save(current); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final scope = _scope; final unusedSuggestions = _knownProviders .where((p) => !(scope ?? const []).contains(p)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.defaultScopeHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( l.defaultScopeBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.sm), if (_loading) const Padding( padding: EdgeInsets.symmetric(vertical: FaiSpace.sm), child: SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ) else if (scope == null) Text( _error ?? l.defaultScopeLoadFailed, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ) else ...[ for (var i = 0; i < scope.length; i++) _ScopeRow( index: i, total: scope.length, entry: scope[i], busy: _saving, onUp: () => _moveUp(i), onDown: () => _moveDown(i), onRemove: () => _removeAt(i), ), const SizedBox(height: FaiSpace.sm), Row( children: [ Expanded( child: TextField( controller: _addController, decoration: InputDecoration( labelText: l.defaultScopeAddLabel, hintText: l.defaultScopeAddHint, border: const OutlineInputBorder(), isDense: true, ), onSubmitted: _saving ? null : _addEntry, ), ), const SizedBox(width: FaiSpace.sm), FilledButton.icon( icon: const Icon(Icons.add, size: 14), label: Text(l.defaultScopeAddButton), onPressed: _saving ? null : () => _addEntry(_addController.text), ), ], ), if (unusedSuggestions.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( l.defaultScopeSuggestions, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, ), ), const SizedBox(height: 4), Wrap( spacing: FaiSpace.xs, runSpacing: 4, children: [ for (final s in unusedSuggestions) ActionChip( label: Text( s, style: const TextStyle(fontFamily: 'monospace'), ), onPressed: _saving ? null : () => _addEntry(s), ), ], ), ], if (_error != null) ...[ const SizedBox(height: FaiSpace.sm), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], ], ], ); } } class _ScopeRow extends StatelessWidget { final int index; final int total; final String entry; final bool busy; final VoidCallback onUp; final VoidCallback onDown; final VoidCallback onRemove; const _ScopeRow({ required this.index, required this.total, required this.entry, required this.busy, required this.onUp, required this.onDown, required this.onRemove, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ SizedBox( width: 24, child: Text( '${index + 1}.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text(entry, style: const TextStyle(fontFamily: 'monospace')), ), IconButton( icon: const Icon(Icons.arrow_upward, size: 16), tooltip: 'Move up', onPressed: busy || index == 0 ? null : onUp, visualDensity: VisualDensity.compact, ), IconButton( icon: const Icon(Icons.arrow_downward, size: 16), tooltip: 'Move down', onPressed: busy || index == total - 1 ? null : onDown, visualDensity: VisualDensity.compact, ), IconButton( icon: const Icon(Icons.delete_outline, size: 16), tooltip: 'Remove', onPressed: busy ? null : onRemove, visualDensity: VisualDensity.compact, ), ], ), ); } }