The-Agency/lib/ui/widgets/chat/bubbles/tools/tool_bubble_base.dart

399 lines
10 KiB
Dart

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<ToolBubbleBase> createState() => _ToolBubbleBaseState();
}
class _ToolBubbleBaseState extends State<ToolBubbleBase> {
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<ChatProvider>();
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<ChatProvider>();
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<ChatProvider>();
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<ChatProvider>();
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,
),
),
],
);
}
}