feat: add MCP support (#1471)

This commit is contained in:
Levi authored and GitHub committed 2026-09-05 20:21:39 +08:00
1 parent 39cd9034cd
commit f2145de571
83 files changed
+3119 -363

No files matched your search

@@ -16,6 +16,7 @@ import '../../../../app/application/app_platform_controller.dart';
import '../../../../app/application/app_runtime_controller.dart';
import '../../../../app/application/location_keep_alive.dart';
import '../../../../core/common/start_config.dart';
import '../../../../core/network/gopeed/gopeed_transport.dart';
import '../../../../core/utils/breakpoints.dart';
import '../../../../core/window/app_window_chrome.dart';
import '../../../../shared/theme/app_design_tokens.dart';
@@ -23,6 +24,7 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_choice_segmented_control.dart';
import '../../../../shared/widgets/app_loading_button.dart';
import '../../../../shared/widgets/app_path_picker_field.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/responsive_menu_layout.dart';
import '../../../../l10n/l10n.dart';
@@ -34,6 +36,7 @@ import '../../../home/presentation/widgets/primary_rail.dart';
import '../../application/settings_controller.dart';
import '../widgets/app_update_dialog.dart';
import '../widgets/download_categories_setting.dart';
import '../widgets/mcp_agent_setup.dart';
import '../widgets/settings_language_select.dart';
import '../widgets/settings_content_frame.dart';
import '../widgets/settings_item.dart';
@@ -42,6 +45,11 @@ import '../widgets/theme_settings_control.dart';
enum _SettingsSection { basic, downloads, advanced }
String webApiDisplayAddress(Uri pageUri) {
final host = pageUri.host.toLowerCase() == 'localhost' ? '127.0.0.1' : pageUri.host;
return Uri(host: host, port: pageUri.hasPort ? pageUri.port : null).authority;
}
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key, this.sectionKey});
@@ -69,7 +77,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
final _proxyUserController = TextEditingController();
final _proxyPasswordController = TextEditingController();
final _customTrackersController = TextEditingController();
final _apiAddressController = TextEditingController();
final _apiHostController = TextEditingController();
final _apiPortController = TextEditingController();
final _apiTokenController = TextEditingController();
@@ -82,7 +89,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
bool _syncingControllers = false;
bool _syncingStartControllers = false;
bool _savingStartConfig = false;
String? _apiNetworkDraft;
List<TextEditingController> get _textControllers => [
_downloadDirController,
@@ -105,7 +111,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
];
List<TextEditingController> get _startTextControllers => [
_apiAddressController,
_apiHostController,
_apiPortController,
_apiTokenController,
@@ -149,7 +154,9 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_ErrorState(error: error, onRetry: () => ref.read(settingsControllerProvider.notifier).reload()),
data: (state) {
_syncConfig(state.config);
if (runtimeState != null) {
if (kIsWeb) {
_syncWebStartConfig(state.config.api, runtimeState);
} else if (runtimeState != null) {
_syncStartConfig(runtimeState.startConfig);
}
return ResponsiveMenuLayout<_SettingsSection>(
@@ -279,6 +286,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
) {
final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature;
final apiServerVisualState = _apiServerVisualState(runtimeState);
final palette = AppPalette.of(context);
switch (section) {
case _SettingsSection.basic:
return _SettingsSectionStack(
@@ -678,6 +686,90 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
case _SettingsSection.advanced:
return _SettingsSectionStack(
children: [
_SettingsBlock(
title: 'API',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.apiServerRuntimeStatus,
subtitle: _apiServerRuntimeDetails(runtimeState),
child: _ApiServerStatusLight(
key: const ValueKey('api-server-runtime-status'),
state: apiServerVisualState,
label: switch (apiServerVisualState) {
_ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
_ApiServerVisualState.running => context.l10n.apiServerRunning,
_ApiServerVisualState.stopped => context.l10n.apiServerStopped,
_ApiServerVisualState.failed => context.l10n.apiServerFailed,
},
),
),
SettingsItem(
title: context.l10n.listenAddress,
child: _HostPortControl(
hostController: _apiHostController,
portController: _apiPortController,
hostKey: const ValueKey('api-host-input'),
portKey: const ValueKey('api-port-input'),
enabled: !kIsWeb,
),
),
if (!kIsWeb)
SettingsItem(
title: context.l10n.apiToken,
child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
),
McpSettingsItem(
enabled: _startConfig?.mcpEnable ?? false,
readOnly: kIsWeb,
onChanged: (value) => setState(() => _startConfig?.mcpEnable = value),
onOpenAgentSetup: () => unawaited(_openMcpAgentSetup(runtimeState)),
),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
if (kIsWeb)
AppTooltip(
message: context.l10n.webApiConfigReadOnlyHelp,
child: Padding(
key: const ValueKey('web-api-config-help'),
padding: const EdgeInsets.all(8),
child: Icon(Icons.help_outline, size: 17, color: palette.textMuted),
),
),
AppLoadingButton(
key: const ValueKey('toggle-api-server-button'),
onPressed: kIsWeb || runtimeState == null ? null : _toggleApiServer,
loading: _savingStartConfig,
icon: Icon(
kIsWeb || (runtimeState?.apiServerState.running ?? false)
? Icons.stop_circle_outlined
: Icons.play_circle_outline,
),
child: Text(
kIsWeb || (runtimeState?.apiServerState.running ?? false)
? context.l10n.stopApiServer
: context.l10n.startApiServer,
),
),
AppLoadingButton(
key: const ValueKey('save-api-config-button'),
onPressed: kIsWeb || runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
loading: _savingStartConfig,
icon: const Icon(Icons.save_outlined),
variant: AppLoadingButtonVariant.primary,
child: Text(context.l10n.save),
),
],
),
),
],
),
),
_SettingsBlock(
title: context.l10n.network,
child: _SettingsGroup(
@@ -762,94 +854,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
],
),
),
if (!kIsWeb)
_SettingsBlock(
title: 'API',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.apiServerRuntimeStatus,
subtitle: _apiServerRuntimeDetails(runtimeState),
child: _ApiServerStatusLight(
key: const ValueKey('api-server-runtime-status'),
state: apiServerVisualState,
label: switch (apiServerVisualState) {
_ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
_ApiServerVisualState.running => context.l10n.apiServerRunning,
_ApiServerVisualState.stopped => context.l10n.apiServerStopped,
_ApiServerVisualState.failed => context.l10n.apiServerFailed,
},
),
),
SettingsItem(
title: context.l10n.protocol,
child: AppChoiceSegmentedControl<String>(
value: _apiNetworkValue,
buttonKeyPrefix: 'settings-choice',
options: [
const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined),
if (Util.supportUnixSocket())
const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined),
],
onChanged: _changeApiNetwork,
),
),
if (_apiNetworkValue == 'unix')
SettingsItem(
title: context.l10n.socketPath,
child: _TextSettingControl(controller: _apiAddressController),
)
else
SettingsItem(
title: context.l10n.listenAddress,
child: _HostPortControl(
hostController: _apiHostController,
portController: _apiPortController,
hostKey: const ValueKey('api-host-input'),
portKey: const ValueKey('api-port-input'),
),
),
if (_apiNetworkValue == 'tcp')
SettingsItem(
title: context.l10n.apiToken,
child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
AppLoadingButton(
key: const ValueKey('toggle-api-server-button'),
onPressed: runtimeState == null ? null : _toggleApiServer,
loading: _savingStartConfig,
icon: Icon(
runtimeState?.apiServerState.running ?? false
? Icons.stop_circle_outlined
: Icons.play_circle_outline,
),
child: Text(
runtimeState?.apiServerState.running ?? false
? context.l10n.stopApiServer
: context.l10n.startApiServer,
),
),
AppLoadingButton(
key: const ValueKey('save-api-config-button'),
onPressed: runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
loading: _savingStartConfig,
icon: const Icon(Icons.save_outlined),
variant: AppLoadingButtonVariant.primary,
child: Text(context.l10n.save),
),
],
),
),
],
),
),
_SettingsBlock(
title: context.l10n.developer,
child: _SettingsGroup(
@@ -905,8 +909,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
subtitle: _logDirectoryLabel,
child: shad.SecondaryButton(
onPressed: kIsWeb ? null : _openLogDirectory,
leading: const Icon(Icons.folder_open_outlined),
child: Text(context.l10n.open),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.folder_open_outlined),
const SizedBox(width: AppDesignTokens.space8),
Text(context.l10n.open),
],
),
),
),
],
@@ -957,15 +967,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_startConfig!.address == config.address &&
_startConfig!.apiToken == config.apiToken) {
_startConfig!.apiEnable = config.apiEnable;
_startConfig!.mcpEnable = config.mcpEnable;
_loadedStartSignature = signature;
return;
}
_loadedStartSignature = signature;
_startConfig = _copyStartConfig(config);
_apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp';
_syncingStartControllers = true;
_apiAddressController.text = _startConfig!.address;
if (_apiNetworkDraft == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) {
if (config.network == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) {
final apiAddress = _splitHostPort(_startConfig!.address);
_apiHostController.text = apiAddress.host;
_apiPortController.text = apiAddress.port;
@@ -977,6 +986,19 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_syncingStartControllers = false;
}
void _syncWebStartConfig(ApiServerConfig config, AppRuntimeState? runtimeState) {
final webConfig = StartConfig()
..network = 'tcp'
..address = webApiDisplayAddress(_webApiUri(runtimeState))
..apiEnable = true
..mcpEnable = config.mcpEnable
..storage = ''
..storageDir = ''
..refreshInterval = 0
..apiToken = config.token;
_syncStartConfig(webConfig);
}
void _scheduleTextSave() {
if (_syncingControllers || _config == null) {
return;
@@ -1055,10 +1077,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
void _applyStartControllers(StartConfig config) {
config.network = _apiNetworkDraft ?? config.network;
config.address = config.network == 'unix'
? _apiAddressController.text.trim()
: _joinHostPort(_apiHostController.text, _apiPortController.text);
config.network = 'tcp';
config.address = _joinHostPort(_apiHostController.text, _apiPortController.text);
config.apiToken = _apiTokenController.text.trim();
}
@@ -1184,10 +1204,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
Future<String?> _validateStartConfig(StartConfig candidate) async {
final l10n = context.l10n;
if (candidate.network == 'unix') {
return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null;
}
final address = _splitHostPort(candidate.address);
if (address.host.isEmpty) return l10n.serverRequired;
final port = int.tryParse(address.port);
@@ -1196,29 +1212,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
return null;
}
void _changeApiNetwork(String value) {
final config = _startConfig;
if (config == null || _apiNetworkDraft == value) return;
setState(() {
_apiNetworkDraft = value;
_syncingStartControllers = true;
if (value == 'tcp' && (_apiHostController.text.isEmpty || _apiPortController.text.isEmpty)) {
if (_looksLikeTcpAddress(config.address)) {
final address = _splitHostPort(config.address);
_apiHostController.text = address.host;
_apiPortController.text = address.port;
} else {
_apiHostController.text = '127.0.0.1';
_apiPortController.text = '9999';
}
}
if (value == 'unix' && (_apiAddressController.text.isEmpty || _looksLikeTcpAddress(_apiAddressController.text))) {
_apiAddressController.text = '${Util.getStorageDir()}/$unixSocketPath';
}
_syncingStartControllers = false;
});
}
Future<void> _runAction(Future<void> Function() action) async {
try {
await action();
@@ -1422,15 +1415,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
String _startSignature(StartConfig config) {
return '${config.apiEnable}|${config.network}|${config.address}|${config.apiToken}';
return '${config.apiEnable}|${config.mcpEnable}|${config.network}|${config.address}|${config.apiToken}';
}
String _startDraftSignature() {
final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp';
final address = network == 'unix'
? _apiAddressController.text.trim()
: _joinHostPort(_apiHostController.text, _apiPortController.text);
return '${_startConfig?.apiEnable ?? false}|$network|$address|${_apiTokenController.text.trim()}';
final address = _joinHostPort(_apiHostController.text, _apiPortController.text);
return '${_startConfig?.apiEnable ?? false}|${_startConfig?.mcpEnable ?? false}|tcp|$address|${_apiTokenController.text.trim()}';
}
StartConfig _copyStartConfig(StartConfig config) {
@@ -1438,6 +1428,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
..mcpEnable = config.mcpEnable
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval
@@ -1445,14 +1436,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
..webViewRpcConfig = config.webViewRpcConfig;
}
String get _apiNetworkValue {
final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp';
if (network == 'unix' && Util.supportUnixSocket()) {
return 'unix';
}
return 'tcp';
}
String get _proxyMode {
final proxy = _config?.proxy;
if (proxy == null || !proxy.enable) return 'none';
@@ -1506,6 +1489,40 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
}
Future<void> _openMcpAgentSetup(AppRuntimeState? runtimeState) async {
final mcpRunning = kIsWeb
? (_startConfig?.mcpEnable ?? false)
: runtimeState?.apiServerState.running == true && runtimeState?.apiServerState.mcpEnabled == true;
await showMcpAgentSetupDialog(
context,
endpoint: _mcpEndpoint(runtimeState),
apiToken: _apiTokenController.text.trim(),
mcpRunning: mcpRunning,
allowManualApiTokenTemplate: kIsWeb,
);
}
String _mcpEndpoint(AppRuntimeState? runtimeState) {
if (kIsWeb) {
return webMcpEndpoint(_webApiUri(runtimeState));
}
final apiState = runtimeState?.apiServerState;
final address = apiState != null && apiState.running && apiState.network == 'tcp'
? apiState.runningAddress()
: _joinHostPort(_apiHostController.text, _apiPortController.text);
final parts = _splitHostPort(address);
var host = parts.host;
if (host.startsWith('[') && host.endsWith(']')) {
host = host.substring(1, host.length - 1);
}
if (host == '0.0.0.0' || host == '::') {
host = host == '::' ? '::1' : '127.0.0.1';
}
final port = int.tryParse(parts.port);
return Uri(scheme: 'http', host: host, port: port, path: '/mcp').toString();
}
bool _looksLikeTcpAddress(String address) {
final separator = address.lastIndexOf(':');
if (separator <= 0 || separator == address.length - 1) {
@@ -1556,10 +1573,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) {
if (runtimeState == null) return null;
if (kIsWeb) {
final apiUri = _webApiUri(runtimeState);
return '${apiUri.scheme}://${webApiDisplayAddress(apiUri)}';
}
final apiState = runtimeState.apiServerState;
final details = <String>[];
if (apiState.running) {
details.add('${apiState.network}://${apiState.runningAddress()}');
details.add('http://${apiState.runningAddress()}');
}
if (apiState.lastError.isNotEmpty) {
details.add(apiState.lastError);
@@ -1569,8 +1590,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
return details.isEmpty ? null : details.join('\n');
}
Uri _webApiUri(AppRuntimeState? runtimeState) {
final address = runtimeState?.startConfig.address ?? '';
return Uri.parse(normalizeWebApiBaseUrl(address, Uri.base));
}
_ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) {
if (runtimeState == null) return _ApiServerVisualState.loading;
if (kIsWeb) return _ApiServerVisualState.running;
final apiState = runtimeState.apiServerState;
if (apiState.running) return _ApiServerVisualState.running;
if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed;
@@ -1875,12 +1902,14 @@ class _HostPortControl extends StatelessWidget {
required this.portController,
required this.hostKey,
required this.portKey,
this.enabled = true,
});
final TextEditingController hostController;
final TextEditingController portController;
final Key hostKey;
final Key portKey;
final bool enabled;
@override
Widget build(BuildContext context) {
@@ -1890,7 +1919,12 @@ class _HostPortControl extends StatelessWidget {
child: Row(
children: [
Expanded(
child: shad.TextField(key: hostKey, controller: hostController, hintText: context.l10n.server),
child: shad.TextField(
key: hostKey,
controller: hostController,
hintText: context.l10n.server,
enabled: enabled,
),
),
const SizedBox(width: AppDesignTokens.space8),
SizedBox(
@@ -1903,6 +1937,7 @@ class _HostPortControl extends StatelessWidget {
step: 1,
decimalPlaces: 0,
hintText: context.l10n.port,
enabled: enabled,
),
),
],
@@ -1920,6 +1955,7 @@ class _NumberInput extends StatefulWidget {
this.fieldKey,
this.max,
this.hintText,
this.enabled = true,
});
final TextEditingController controller;
@@ -1929,6 +1965,7 @@ class _NumberInput extends StatefulWidget {
final int decimalPlaces;
final Key? fieldKey;
final String? hintText;
final bool enabled;
@override
State<_NumberInput> createState() => _NumberInputState();
@@ -1993,6 +2030,7 @@ class _NumberInputState extends State<_NumberInput> {
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.number,
inputFormatters: formatters,
enabled: widget.enabled,
features: [
shad.InputFeature.spinner(
step: widget.step,
@@ -2130,8 +2168,14 @@ class _BrowserExtensionLinks extends StatelessWidget {
shad.SecondaryButton(
key: ValueKey('browser-extension-${link.label.toLowerCase()}'),
onPressed: () => onOpen(link.uri),
leading: const Icon(Icons.open_in_new, size: 15),
child: Text(link.label),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.open_in_new, size: 15),
const SizedBox(width: AppDesignTokens.space8),
Text(link.label),
],
),
),
],
);
@@ -64,8 +64,14 @@ class DownloadCategoriesControl extends StatelessWidget {
child: shad.SecondaryButton(
key: const ValueKey('add-download-category'),
onPressed: onAdd,
leading: const Icon(Icons.add, size: 17),
child: Text(context.l10n.add),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 17),
const SizedBox(width: AppDesignTokens.space8),
Text(context.l10n.add),
],
),
),
),
],
@@ -0,0 +1,748 @@
import 'dart:convert';
import 'package:flutter/material.dart' show Icons, Scrollbar, ScrollbarOrientation, SelectableText;
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_copy_icon_button.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import 'settings_item.dart';
class McpSettingsItem extends StatelessWidget {
const McpSettingsItem({
super.key,
required this.enabled,
required this.readOnly,
required this.onChanged,
required this.onOpenAgentSetup,
});
final bool enabled;
final bool readOnly;
final ValueChanged<bool> onChanged;
final VoidCallback onOpenAgentSetup;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return SettingsItem(
title: 'MCP',
subtitle: context.l10n.mcpDescription,
subtitleAction: _McpAgentLink(onPressed: onOpenAgentSetup),
titleTrailing: Icon(
key: const ValueKey('mcp-ai-icon'),
Icons.auto_awesome_outlined,
size: 14,
color: palette.brandProgress,
),
child: shad.Switch(
key: const ValueKey('mcp-endpoint-switch'),
value: enabled,
enabled: !readOnly,
onChanged: onChanged,
),
);
}
}
class _McpAgentLink extends StatefulWidget {
const _McpAgentLink({required this.onPressed});
final VoidCallback onPressed;
@override
State<_McpAgentLink> createState() => _McpAgentLinkState();
}
class _McpAgentLinkState extends State<_McpAgentLink> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final color = _hovered ? palette.brandProgress : palette.brand;
return Semantics(
link: true,
label: context.l10n.connectAgent,
button: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
key: const ValueKey('open-mcp-agent-setup'),
behavior: HitTestBehavior.opaque,
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.connectAgent,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: color,
decorationThickness: _hovered ? 1.5 : 1,
),
),
const SizedBox(width: 3),
Icon(Icons.arrow_forward, size: 13, color: color),
],
),
),
),
),
);
}
}
enum _McpAgent {
codex,
claudeCode,
cursor,
githubCopilot,
windsurf,
geminiCli,
cline,
openCode,
trae,
qoder,
workBuddy,
zCode,
deepSeekHarness,
pi,
}
extension on _McpAgent {
String get label => switch (this) {
_McpAgent.codex => 'Codex',
_McpAgent.claudeCode => 'Claude Code',
_McpAgent.cursor => 'Cursor',
_McpAgent.githubCopilot => 'GitHub Copilot',
_McpAgent.windsurf => 'Windsurf',
_McpAgent.geminiCli => 'Gemini CLI',
_McpAgent.cline => 'Cline',
_McpAgent.openCode => 'OpenCode',
_McpAgent.trae => 'TRAE',
_McpAgent.qoder => 'Qoder',
_McpAgent.workBuddy => 'WorkBuddy',
_McpAgent.zCode => 'ZCode',
_McpAgent.deepSeekHarness => 'DeepSeek Harness',
_McpAgent.pi => 'Pi',
};
String get iconAsset => switch (this) {
_McpAgent.codex => 'assets/agent_icons/codex.svg',
_McpAgent.claudeCode => 'assets/agent_icons/claude_code.svg',
_McpAgent.cursor => 'assets/agent_icons/cursor.svg',
_McpAgent.githubCopilot => 'assets/agent_icons/github_copilot.svg',
_McpAgent.windsurf => 'assets/agent_icons/windsurf.svg',
_McpAgent.geminiCli => 'assets/agent_icons/gemini_cli.svg',
_McpAgent.cline => 'assets/agent_icons/cline.svg',
_McpAgent.openCode => 'assets/agent_icons/opencode.svg',
_McpAgent.trae => 'assets/agent_icons/trae.svg',
_McpAgent.qoder => 'assets/agent_icons/qoder.svg',
_McpAgent.workBuddy => 'assets/agent_icons/codebuddy.svg',
_McpAgent.zCode => 'assets/agent_icons/zcode.svg',
_McpAgent.deepSeekHarness => 'assets/agent_icons/deepseek.svg',
_McpAgent.pi => 'assets/agent_icons/pi.svg',
};
bool get usesThemeTint => switch (this) {
_McpAgent.cursor ||
_McpAgent.githubCopilot ||
_McpAgent.windsurf ||
_McpAgent.cline ||
_McpAgent.openCode ||
_McpAgent.zCode ||
_McpAgent.pi => true,
_ => false,
};
}
String webMcpEndpoint(Uri pageUri) {
return Uri(
scheme: pageUri.scheme,
host: pageUri.host,
port: pageUri.hasPort ? pageUri.port : null,
path: '/mcp',
).toString();
}
Future<void> showMcpAgentSetupDialog(
BuildContext context, {
required String endpoint,
required String apiToken,
required bool mcpRunning,
bool allowManualApiTokenTemplate = false,
}) async {
var selectedAgent = _McpAgent.codex;
var useManualApiTokenTemplate = false;
final overlay = const shad.DialogOverlayHandler().show<void>(
context: context,
alignment: Alignment.center,
barrierDismissable: true,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) {
final palette = AppPalette.of(dialogContext);
final screenSize = MediaQuery.sizeOf(dialogContext);
final dialogWidth = (screenSize.width - 80).clamp(220.0, 620.0);
final contentMaxHeight = (screenSize.height - 140).clamp(120.0, 460.0);
final templateToken = allowManualApiTokenTemplate && useManualApiTokenTemplate ? '<API_TOKEN>' : '';
final displayToken = allowManualApiTokenTemplate ? templateToken : _maskedToken(apiToken);
final clipboardToken = allowManualApiTokenTemplate ? templateToken : apiToken;
final displaySnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: displayToken);
final clipboardSnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: clipboardToken);
return shad.AlertDialog(
key: const ValueKey('mcp-agent-setup-dialog'),
padding: const EdgeInsets.all(AppDesignTokens.space24),
title: SizedBox(
width: dialogWidth,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: palette.brandSoft,
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
alignment: Alignment.center,
child: Icon(Icons.auto_awesome_outlined, size: 19, color: palette.brandProgress),
),
const SizedBox(width: AppDesignTokens.space12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(dialogContext.l10n.connectMcpAgent),
const SizedBox(height: 4),
Text(
dialogContext.l10n.mcpAgentDialogDescription,
style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400),
),
],
),
),
AppTooltip(
message: dialogContext.l10n.close,
child: shad.IconButton.ghost(
key: const ValueKey('close-mcp-agent-setup'),
size: shad.ButtonSize.xSmall,
onPressed: () => shad.closeOverlay(dialogContext),
icon: const Icon(Icons.close, size: 18),
),
),
],
),
),
content: SizedBox(
width: dialogWidth,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: contentMaxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!mcpRunning) ...[
Container(
key: const ValueKey('mcp-not-running-hint'),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space12,
vertical: AppDesignTokens.space8,
),
decoration: BoxDecoration(
color: palette.brandSoft,
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
border: Border.all(color: palette.brandTrack),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 16, color: palette.brandProgress),
const SizedBox(width: AppDesignTokens.space8),
Expanded(
child: Text(
dialogContext.l10n.mcpNotRunningHint,
style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.4),
),
),
],
),
),
const SizedBox(height: AppDesignTokens.space16),
],
_McpAgentGrid(
selected: selectedAgent,
onSelected: (value) => setDialogState(() => selectedAgent = value),
),
const SizedBox(height: AppDesignTokens.space16),
Row(
children: [
_McpAgentLogo(agent: selectedAgent, size: 20),
const SizedBox(width: AppDesignTokens.space8),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
'${selectedAgent.label} · MCP',
style: TextStyle(
color: palette.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
if (allowManualApiTokenTemplate) ...[
const SizedBox(width: 2),
_McpApiTokenTemplateHint(
key: ValueKey('mcp-api-token-template-help-$useManualApiTokenTemplate'),
onUseTemplate: () => setDialogState(() => useManualApiTokenTemplate = true),
),
],
],
),
),
AppCopyIconButton(key: const ValueKey('copy-mcp-agent-snippet'), text: clipboardSnippet),
],
),
const SizedBox(height: AppDesignTokens.space8),
_McpSnippetBox(snippet: displaySnippet),
],
),
),
),
),
);
},
),
);
await overlay.future;
}
class _McpApiTokenTemplateHint extends StatelessWidget {
const _McpApiTokenTemplateHint({super.key, required this.onUseTemplate});
final VoidCallback onUseTemplate;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return shad.HoverCard(
wait: const Duration(milliseconds: 180),
debounce: const Duration(milliseconds: 350),
popoverAlignment: Alignment.topLeft,
anchorAlignment: Alignment.bottomLeft,
popoverOffset: const Offset(0, 6),
hoverBuilder: (hoverContext) {
final hoverPalette = AppPalette.of(hoverContext);
return Container(
key: const ValueKey('mcp-api-token-template-hint'),
width: 288,
padding: const EdgeInsets.all(AppDesignTokens.space12),
decoration: BoxDecoration(
color: hoverPalette.cardBg,
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
border: Border.all(color: hoverPalette.border),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.16),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
hoverContext.l10n.webMcpApiTokenHint,
style: TextStyle(color: hoverPalette.textSecondary, fontSize: 12, height: 1.45),
),
const SizedBox(height: 6),
Semantics(
link: true,
button: true,
label: hoverContext.l10n.useMcpApiTokenTemplate,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: const ValueKey('use-mcp-api-token-template'),
behavior: HitTestBehavior.opaque,
onTap: onUseTemplate,
child: Text(
hoverContext.l10n.useMcpApiTokenTemplate,
style: TextStyle(
color: hoverPalette.brand,
fontSize: 12,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: hoverPalette.brand,
),
),
),
),
),
],
),
);
},
child: Semantics(
label: context.l10n.webMcpApiTokenHint,
child: SizedBox(
key: const ValueKey('mcp-api-token-template-help'),
width: 28,
height: 28,
child: Icon(Icons.help_outline, size: 16, color: palette.textMuted),
),
),
);
}
}
class _McpAgentGrid extends StatelessWidget {
const _McpAgentGrid({required this.selected, required this.onSelected});
final _McpAgent selected;
final ValueChanged<_McpAgent> onSelected;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = switch (constraints.maxWidth) {
>= 520 => 8,
>= 380 => 6,
>= 260 => 5,
_ => 4,
};
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: AppDesignTokens.space8,
mainAxisSpacing: AppDesignTokens.space8,
),
itemCount: _McpAgent.values.length,
itemBuilder: (context, index) {
final agent = _McpAgent.values[index];
return _McpAgentButton(
key: ValueKey('mcp-agent-${_agentKey(agent)}'),
agent: agent,
selected: agent == selected,
onPressed: () => onSelected(agent),
);
},
);
},
);
}
}
class _McpAgentButton extends StatefulWidget {
const _McpAgentButton({super.key, required this.agent, required this.selected, required this.onPressed});
final _McpAgent agent;
final bool selected;
final VoidCallback onPressed;
@override
State<_McpAgentButton> createState() => _McpAgentButtonState();
}
class _McpAgentButtonState extends State<_McpAgentButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final background = widget.selected
? palette.brandSoft
: _hovered
? palette.cardHoverBg
: palette.cardBg;
return Semantics(
button: true,
selected: widget.selected,
label: widget.agent.label,
child: AppTooltip(
message: widget.agent.label,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius + 2),
border: Border.all(
color: widget.selected ? palette.brandProgress : palette.border,
width: widget.selected ? 1.5 : 1,
),
),
alignment: Alignment.center,
child: _McpAgentLogo(agent: widget.agent, size: 28),
),
),
),
),
);
}
}
class _McpAgentLogo extends StatelessWidget {
const _McpAgentLogo({required this.agent, required this.size});
final _McpAgent agent;
final double size;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return SvgPicture.asset(
agent.iconAsset,
width: size,
height: size,
fit: BoxFit.contain,
excludeFromSemantics: true,
colorFilter: agent.usesThemeTint ? ColorFilter.mode(palette.textPrimary, BlendMode.srcIn) : null,
);
}
}
class _McpSnippetBox extends StatefulWidget {
const _McpSnippetBox({required this.snippet});
final String snippet;
@override
State<_McpSnippetBox> createState() => _McpSnippetBoxState();
}
class _McpSnippetBoxState extends State<_McpSnippetBox> {
final _horizontalController = ScrollController();
final _verticalController = ScrollController();
@override
void didUpdateWidget(covariant _McpSnippetBox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.snippet == oldWidget.snippet) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_horizontalController.hasClients) _horizontalController.jumpTo(0);
if (_verticalController.hasClients) _verticalController.jumpTo(0);
});
}
@override
void dispose() {
_horizontalController.dispose();
_verticalController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Container(
key: const ValueKey('mcp-agent-snippet'),
height: 176,
width: double.infinity,
padding: const EdgeInsets.all(14),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: palette.surfaceSoft,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: palette.border),
),
child: LayoutBuilder(
builder: (context, constraints) {
final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp(0.0, double.infinity);
return Scrollbar(
key: const ValueKey('mcp-snippet-horizontal-scrollbar'),
controller: _horizontalController,
thumbVisibility: true,
scrollbarOrientation: ScrollbarOrientation.bottom,
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(bottom: AppDesignTokens.space12),
child: SingleChildScrollView(
controller: _horizontalController,
scrollDirection: Axis.horizontal,
child: SizedBox(
height: contentHeight,
child: Scrollbar(
key: const ValueKey('mcp-snippet-vertical-scrollbar'),
controller: _verticalController,
thumbVisibility: true,
scrollbarOrientation: ScrollbarOrientation.right,
notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical,
child: SingleChildScrollView(
controller: _verticalController,
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: SelectableText(
widget.snippet,
style: TextStyle(
color: palette.textPrimary,
fontSize: 12,
height: 1.55,
fontFamily: 'monospace',
),
),
),
),
),
),
),
),
),
);
},
),
);
}
}
String _agentKey(_McpAgent agent) => switch (agent) {
_McpAgent.cursor => 'cursor',
_McpAgent.claudeCode => 'claude-code',
_McpAgent.codex => 'codex',
_McpAgent.githubCopilot => 'github-copilot',
_McpAgent.windsurf => 'windsurf',
_McpAgent.geminiCli => 'gemini-cli',
_McpAgent.cline => 'cline',
_McpAgent.openCode => 'opencode',
_McpAgent.trae => 'trae',
_McpAgent.qoder => 'qoder',
_McpAgent.workBuddy => 'workbuddy',
_McpAgent.zCode => 'zcode',
_McpAgent.deepSeekHarness => 'deepseek-harness',
_McpAgent.pi => 'pi',
};
String _agentSnippet(_McpAgent agent, {required String endpoint, required String apiToken}) {
return switch (agent) {
_McpAgent.cursor => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken),
_McpAgent.codex => _codexSnippet(endpoint: endpoint, apiToken: apiToken),
_McpAgent.claudeCode =>
apiToken.isNotEmpty
? 'claude mcp add --transport http gopeed $endpoint \\\n'
" --header ${_shellSingleQuote('Authorization: Bearer $apiToken')}"
: 'claude mcp add --transport http gopeed $endpoint',
_McpAgent.githubCopilot => _mcpServersSnippet(
rootKey: 'servers',
type: 'http',
endpoint: endpoint,
apiToken: apiToken,
),
_McpAgent.windsurf => _mcpServersSnippet(urlKey: 'serverUrl', endpoint: endpoint, apiToken: apiToken),
_McpAgent.geminiCli => _mcpServersSnippet(urlKey: 'httpUrl', endpoint: endpoint, apiToken: apiToken),
_McpAgent.cline => _mcpServersSnippet(
type: 'streamableHttp',
endpoint: endpoint,
apiToken: apiToken,
extra: const <String, Object>{'disabled': false, 'autoApprove': <Object>[]},
),
_McpAgent.openCode => _openCodeSnippet(endpoint: endpoint, apiToken: apiToken),
_McpAgent.trae => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken),
_McpAgent.qoder => _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken),
_McpAgent.workBuddy => _mcpServersSnippet(
type: 'streamableHttp',
endpoint: endpoint,
apiToken: apiToken,
extra: const <String, Object>{'timeout': 30000},
),
_McpAgent.zCode => _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken),
_McpAgent.deepSeekHarness => _deepSeekHarnessSnippet(endpoint: endpoint, apiToken: apiToken),
_McpAgent.pi => _piSnippet(endpoint: endpoint, apiToken: apiToken),
};
}
String _codexSnippet({required String endpoint, required String apiToken}) {
if (apiToken.isEmpty) {
return 'codex mcp add gopeed --url ${_shellSingleQuote(endpoint)}';
}
return 'export GOPEED_API_TOKEN=${_shellSingleQuote(apiToken)}\n\n'
'codex mcp add gopeed --url ${_shellSingleQuote(endpoint)} \\\n'
' --bearer-token-env-var GOPEED_API_TOKEN';
}
String _mcpServersSnippet({
String rootKey = 'mcpServers',
String urlKey = 'url',
String? type,
required String endpoint,
required String apiToken,
Map<String, Object> extra = const <String, Object>{},
}) {
final server = <String, Object>{};
if (type != null) {
server['type'] = type;
}
server[urlKey] = endpoint;
if (apiToken.isNotEmpty) {
server['headers'] = <String, String>{'Authorization': 'Bearer $apiToken'};
}
server.addAll(extra);
return const JsonEncoder.withIndent(' ').convert(<String, Object>{
rootKey: <String, Object>{'gopeed': server},
});
}
String _openCodeSnippet({required String endpoint, required String apiToken}) {
final server = <String, Object>{'type': 'remote', 'url': endpoint, 'oauth': false};
if (apiToken.isNotEmpty) {
server['headers'] = <String, String>{'Authorization': 'Bearer $apiToken'};
}
return const JsonEncoder.withIndent(' ').convert(<String, Object>{
r'$schema': 'https://opencode.ai/config.json',
'mcp': <String, Object>{
'servers': <String, Object>{'gopeed': server},
},
});
}
String _deepSeekHarnessSnippet({required String endpoint, required String apiToken}) {
final headers = apiToken.isNotEmpty
? '\n headers:\n Authorization: ${jsonEncode('Bearer $apiToken')}'
: '\n headers: {}';
return '- id: mcp-gopeed\n'
" name: '@deepseek-ai/dsh-mcp-client'\n"
' config:\n'
' serverName: gopeed\n'
' transport: streamable-http\n'
' url: $endpoint$headers';
}
String _piSnippet({required String endpoint, required String apiToken}) {
final config = _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken);
return 'pi install npm:pi-codemcp\n\n# ~/.pi/agent/mcp.json\n$config';
}
String _maskedToken(String token) {
if (token.isEmpty) return '';
if (token.length <= 8) return '••••••••';
return '${token.substring(0, 4)}••••••••${token.substring(token.length - 4)}';
}
String _shellSingleQuote(String value) => "'${value.replaceAll("'", "'\"'\"'")}'";
@@ -5,10 +5,19 @@ import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
class SettingsItem extends StatelessWidget {
const SettingsItem({super.key, required this.title, required this.child, this.subtitle});
const SettingsItem({
super.key,
required this.title,
required this.child,
this.subtitle,
this.subtitleAction,
this.titleTrailing,
});
final String title;
final String? subtitle;
final Widget? subtitleAction;
final Widget? titleTrailing;
final Widget child;
@override
@@ -29,7 +38,12 @@ class SettingsItem extends StatelessWidget {
children: [
const SizedBox(width: leadingSpace),
Expanded(
child: _SettingsItemLabel(title: title, subtitle: subtitle),
child: _SettingsItemLabel(
title: title,
subtitle: subtitle,
subtitleAction: subtitleAction,
titleTrailing: titleTrailing,
),
),
const SizedBox(width: columnGap),
ConstrainedBox(
@@ -45,7 +59,12 @@ class SettingsItem extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsItemLabel(title: title, subtitle: subtitle),
_SettingsItemLabel(
title: title,
subtitle: subtitle,
subtitleAction: subtitleAction,
titleTrailing: titleTrailing,
),
const SizedBox(height: 10),
Align(alignment: Alignment.centerLeft, child: child),
],
@@ -54,10 +73,12 @@ class SettingsItem extends StatelessWidget {
}
class _SettingsItemLabel extends StatelessWidget {
const _SettingsItemLabel({required this.title, this.subtitle});
const _SettingsItemLabel({required this.title, this.subtitle, this.subtitleAction, this.titleTrailing});
final String title;
final String? subtitle;
final Widget? subtitleAction;
final Widget? titleTrailing;
@override
Widget build(BuildContext context) {
@@ -66,13 +87,30 @@ class _SettingsItemLabel extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
title,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
),
),
if (titleTrailing != null) ...[const SizedBox(width: 5), titleTrailing!],
],
),
if (subtitleText != null && subtitleText.isNotEmpty) ...[
const SizedBox(height: 4),
Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 2,
children: [
Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)),
?subtitleAction,
],
),
],
],
);
@@ -99,8 +99,14 @@ class SettingsListEditor extends StatelessWidget {
child: shad.SecondaryButton(
key: addButtonKey,
onPressed: onAdd,
leading: const Icon(Icons.add, size: 17),
child: Text(addLabel ?? context.l10n.add),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 17),
const SizedBox(width: AppDesignTokens.space8),
Text(addLabel ?? context.l10n.add),
],
),
),
),
],
@@ -280,8 +286,16 @@ Future<GithubMirrorDraft?> showGithubMirrorDialog(BuildContext context, {GithubM
for (final type in GithubMirrorType.values)
shad.SecondaryButton(
onPressed: () => setDialogState(() => selectedType = type),
leading: selectedType == type ? const Icon(Icons.check, size: 15) : null,
child: Text(type.name),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedType == type) ...[
const Icon(Icons.check, size: 15),
const SizedBox(width: AppDesignTokens.space8),
],
Text(type.name),
],
),
),
],
),