import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../../providers/chat_provider.dart"; import "../permission_decision.dart"; class ToolBubbleBase extends StatefulWidget { const ToolBubbleBase({ super.key, required this.toolName, required this.icon, this.detail, this.body, this.result, this.pendingPermission, }); final String toolName; final IconData icon; final String? detail; final Widget? body; final String? result; final PendingPermission? pendingPermission; @override State createState() => _ToolBubbleBaseState(); } class _ToolBubbleBaseState extends State { late final TextEditingController _ruleController; @override void initState() { super.initState(); _ruleController = TextEditingController( text: widget.pendingPermission?.suggestionRule ?? widget.toolName, ); } @override void didUpdateWidget(ToolBubbleBase old) { super.didUpdateWidget(old); final newRule = widget.pendingPermission?.suggestionRule ?? widget.toolName; final oldRule = old.pendingPermission?.suggestionRule ?? old.toolName; if (newRule != oldRule) { _ruleController.text = newRule; } } @override void dispose() { _ruleController.dispose(); super.dispose(); } bool get _isFileTool { const ft = {"Edit", "Write", "Read", "MultiEdit"}; return ft.contains(widget.toolName); } bool get _isBash => widget.toolName == "Bash"; bool get _isWebFetch => widget.toolName == "WebFetch" || widget.toolName == "WebSearch"; // extract hostname from a url for the WebFetch "dont ask again" label String _hostnameFromPending() { final pp = widget.pendingPermission; if (pp == null) return ""; final url = pp.input["url"] as String? ?? pp.input["query"] as String? ?? ""; try { return Uri.parse(url).host; } catch (_) { return url; } } // rule to persist for webfetch — "WebFetch(hostname:*)" String _webFetchPersistRule() { final host = _hostnameFromPending(); if (host.isEmpty) return widget.toolName; return "WebFetch($host:*)"; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final pp = widget.pendingPermission; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ OutlinedContainer( clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ IntrinsicHeight( child: Row( children: [ Container( color: theme.colorScheme.primary.scaleAlpha(0.5), padding: EdgeInsets.symmetric( horizontal: 20, vertical: 12, ), child: Row( children: [ Icon(widget.icon).iconSmall, Gap(8), Text(widget.toolName).textSmall, ], ), ), VerticalDivider(), if (widget.detail != null)...[ Gap(16), Text(widget.detail!).mono.xSmall ] ], ), ), if (widget.body != null) ...[ Divider(), widget.body!, ], if (widget.result != null) ...[ Divider(), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: SelectableText( "\u200B${widget.result!}", style: TextStyle( color: theme.colorScheme.mutedForeground, ), ).xSmall.mono, ) ] ], ), ), if (pp != null) ...[ Gap(8), if (_isBash) ...[ // bash: editable rule field + buttons _BashPermissionButtons(ruleController: _ruleController), ] else if (_isWebFetch) ...[ // webfetch: domain-scoped dont-ask-again _WebFetchPermissionButtons( hostname: _hostnameFromPending(), persistRule: _webFetchPersistRule(), ), ] else if (_isFileTool) ...[ // file tools: session-scoped only, no persist _FilePermissionButtons(), ] else ...[ // default: show rule in button _DefaultPermissionButtons( persistRule: pp.suggestionRule ?? widget.toolName, ), ], ], ], ); } } // ─── permission button sets ────────────────────────────────────────────────── class _BashPermissionButtons extends StatelessWidget { const _BashPermissionButtons({required this.ruleController}); final TextEditingController ruleController; @override Widget build(BuildContext context) { final chat = context.read(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Text("Don't ask again for:").xSmall, Gap(8), Expanded( child: TextField( controller: ruleController, style: const TextStyle(fontSize: 12, fontFamily: "monospace"), ), ), ], ), Gap(6), Row( children: [ Expanded( child: Button.outline( leading: Icon(LucideIcons.check).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), child: Text("Yes").small, ), ), Gap(8), Expanded( child: Button.outline( leading: Icon(LucideIcons.checkCheck).iconSmall, onPressed: () => chat.resolvePermission( PermissionDecision.allowAlways, persistRule: ruleController.text.trim(), ), child: Text("Yes, don't ask again").small, ), ), Gap(8), Expanded( child: Button.destructive( leading: Icon(LucideIcons.x).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.reject), child: Text("No").small, ), ), ], ), ], ); } } class _WebFetchPermissionButtons extends StatelessWidget { const _WebFetchPermissionButtons({required this.hostname, required this.persistRule}); final String hostname; final String persistRule; @override Widget build(BuildContext context) { final chat = context.read(); return Row( children: [ Expanded( child: Button.outline( leading: Icon(LucideIcons.check).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), child: Text("Yes").small, ), ), Gap(8), Expanded( child: Button.outline( leading: Icon(LucideIcons.checkCheck).iconSmall, onPressed: () => chat.resolvePermission( PermissionDecision.allowAlways, persistRule: persistRule, ), child: hostname.isNotEmpty ? Text("Yes, always for $hostname").small : Text("Yes, don't ask again").small, ), ), Gap(8), Expanded( child: Button.destructive( leading: Icon(LucideIcons.x).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.reject), child: Text("No").small, ), ), ], ); } } class _FilePermissionButtons extends StatelessWidget { const _FilePermissionButtons(); @override Widget build(BuildContext context) { final chat = context.read(); return Row( children: [ Expanded( child: Button.outline( leading: Icon(LucideIcons.check).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), child: Text("Yes").small, ), ), Gap(8), Expanded( child: Button.outline( leading: Icon(LucideIcons.checkCheck).iconSmall, // no persistRule — session-scoped only onPressed: () => chat.resolvePermission(PermissionDecision.allowAlways), child: Text("Yes, for this session").small, ), ), Gap(8), Expanded( child: Button.destructive( leading: Icon(LucideIcons.x).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.reject), child: Text("No").small, ), ), ], ); } } class _DefaultPermissionButtons extends StatelessWidget { const _DefaultPermissionButtons({required this.persistRule}); final String persistRule; @override Widget build(BuildContext context) { final chat = context.read(); return Row( children: [ Expanded( child: Button.outline( leading: Icon(LucideIcons.check).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce), child: Text("Yes").small, ), ), Gap(8), Expanded( child: Button.outline( leading: Icon(LucideIcons.checkCheck).iconSmall, onPressed: () => chat.resolvePermission( PermissionDecision.allowAlways, persistRule: persistRule, ), child: Text("Yes, don't ask again").small, ), ), Gap(8), Expanded( child: Button.destructive( leading: Icon(LucideIcons.x).iconSmall, onPressed: () => chat.resolvePermission(PermissionDecision.reject), child: Text("No").small, ), ), ], ); } }