399 lines
10 KiB
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,
|
|
),
|
|
),
|
|
|
|
],
|
|
);
|
|
}
|
|
}
|