// FaiSettingsDialog — modal for changing the hub endpoint. // Reads current values from HubService, persists on save via // HubService.reconnect. import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'fai_pill.dart'; import 'fai_system_ai_editor.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; @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(); } 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); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Added MCP server "${draft.name}".')), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Add failed: $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; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Remove failed: $e')), ); } } Future _refreshMcpClients() async { try { final list = await HubService.instance.refreshMcpClients(); if (!mounted) return; setState(() => _mcpClients = list); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Refresh failed: $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) { setState(() { _saving = false; _error = 'port must be 1–65535'; }); 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); return AlertDialog( title: const Text('Hub endpoint'), 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( 'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.lg), TextField( controller: _host, decoration: const InputDecoration( labelText: 'Host', border: OutlineInputBorder(), isDense: true, ), autofocus: true, ), const SizedBox(height: FaiSpace.md), Row( children: [ Expanded( flex: 2, child: TextField( controller: _port, decoration: const InputDecoration( labelText: 'Port', border: OutlineInputBorder(), isDense: true, ), keyboardType: TextInputType.number, ), ), const SizedBox(width: FaiSpace.md), Expanded( flex: 3, child: SwitchListTile( contentPadding: EdgeInsets.zero, dense: true, title: const Text('TLS'), subtitle: Text( 'https/grpc-secure', 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( 'CHANNELS', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( 'Switch hub channel (writes ~/.fai/current-channel and ' 'restarts the daemon). Connect just changes Studio\'s wire.', 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), Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( _channelToast!, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), ], ], 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, ), ], ], ), ), ), actions: [ TextButton( onPressed: _saving ? null : () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save & connect'), ), ], ); } 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); 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 ? 'running' : 'stopped', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), if (active) const Padding( padding: EdgeInsets.only(right: FaiSpace.sm), child: FaiPill(label: 'active', tone: FaiPillTone.success), ), PopupMenuButton( tooltip: 'Channel actions', 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: const _MenuRow( icon: Icons.link, text: 'Connect Studio to this channel', ), ), PopupMenuItem( value: 'switch', enabled: !active && onSwitch != null, child: const _MenuRow( icon: Icons.swap_horiz, text: 'Make this the active channel', ), ), const PopupMenuDivider(), PopupMenuItem( value: 'enable', enabled: onEnableAutostart != null, child: const _MenuRow( icon: Icons.play_circle_outline, text: 'Enable autostart at login', ), ), PopupMenuItem( value: 'disable', enabled: onDisableAutostart != null, child: const _MenuRow( icon: Icons.pause_circle_outline, text: 'Disable autostart', ), ), ], ), ], ), ); } } 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); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'SYSTEM AI', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: status.enabled ? 'enabled' : 'off', 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 ? 'Edit…' : 'Configure…'), ), ], ), 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( 'Off — failure explanations on the audit page are disabled. ' 'Click Configure… to pick a provider (Ollama / OpenAI / LM ' 'Studio / vLLM / Custom). Operator config is rewritten in place; ' 'no daemon restart needed.', 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. /// This is *the* surface that wires the viral-bridge story to /// the operator: configure a server here, watch the store /// fill with synthetic capabilities. 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); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'MCP CLIENTS', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: '${clients.length} server${clients.length == 1 ? "" : "s"}', tone: FaiPillTone.neutral, ), const Spacer(), IconButton( icon: const Icon(Icons.refresh, size: 16), tooltip: 'Re-run discovery', visualDensity: VisualDensity.compact, onPressed: onRefresh, ), IconButton( icon: const Icon(Icons.add, size: 16), tooltip: 'Add MCP server', visualDensity: VisualDensity.compact, onPressed: () async { final draft = await _AddMcpClientDialog.show(context); if (draft != null) onAdd(draft); }, ), ], ), const SizedBox(height: 2), Text( 'External MCP servers federate their tools as `mcp..` capabilities. ' 'Configure once, see them in the Store.', 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( 'No MCP servers configured. Click + to add one.', 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 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: '${client.toolCount} tools', 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: 'Remove server', 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(); @override void dispose() { _name.dispose(); _endpoint.dispose(); _apiKey.dispose(); _desc.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: const Text('Add MCP server'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Tools the server advertises via `tools/list` appear in the Store as ' '`mcp..` capabilities.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), TextField( controller: _name, autofocus: true, decoration: const InputDecoration( labelText: 'Name (no dots)', hintText: 'filesystem', border: OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _endpoint, decoration: const InputDecoration( labelText: 'Endpoint', hintText: 'http://127.0.0.1:3001/mcp', border: OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _apiKey, decoration: const InputDecoration( labelText: 'API key env var (optional)', hintText: 'MCP_FILESYSTEM_TOKEN', prefixText: '\$', border: OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: FaiSpace.sm), TextField( controller: _desc, decoration: const InputDecoration( labelText: 'Notes (optional)', border: OutlineInputBorder(), isDense: true, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: const Text('Cancel'), ), 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: const Text('Add + discover'), ), ], ); } }