Add command files and enhance session management features
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class AdvisorBubble extends StatelessWidget {
|
||||
@@ -6,12 +7,12 @@ class AdvisorBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +22,7 @@ class AdvisorBubble extends StatelessWidget {
|
||||
toolName: "Advisor",
|
||||
icon: LucideIcons.brain,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: model,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,397 @@
|
||||
import "dart:io";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../common/pane_dialog.dart";
|
||||
|
||||
class BashBubble extends StatelessWidget {
|
||||
class BashBubble extends StatefulWidget {
|
||||
const BashBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
State<BashBubble> createState() => _BashBubbleState();
|
||||
}
|
||||
|
||||
class _BashBubbleState extends State<BashBubble> {
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final TextEditingController _ruleController;
|
||||
bool _overflows = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ruleController = TextEditingController(
|
||||
text: widget.pendingPermission?.suggestionRule ?? "Bash",
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOverflow());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(BashBubble oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final newRule = widget.pendingPermission?.suggestionRule ?? "Bash";
|
||||
final oldRule = oldWidget.pendingPermission?.suggestionRule ?? "Bash";
|
||||
if (newRule != oldRule) _ruleController.text = newRule;
|
||||
if (oldWidget.result != widget.result) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOverflow());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_ruleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openFullscreen(
|
||||
BuildContext context,
|
||||
String command,
|
||||
Color termBg,
|
||||
Color termTitleBg,
|
||||
Color termBorder,
|
||||
Color outputGray,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: PaneDialog(
|
||||
title: "Terminal",
|
||||
fillHeight: true,
|
||||
onClose: () => Navigator.of(ctx).pop(),
|
||||
child: Flexible(
|
||||
child: Container(
|
||||
color: termBg,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
|
||||
child: _TerminalPrompt(
|
||||
command: command,
|
||||
cwd: widget.input["cwd"] as String? ??
|
||||
context.read<ChatProvider>().workingDirectory,
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 16),
|
||||
child: SelectableText(
|
||||
widget.result ?? "",
|
||||
style: theme.typography.mono.copyWith(
|
||||
color: outputGray,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _checkOverflow() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final overflows = _scrollController.position.maxScrollExtent > 0;
|
||||
if (overflows != _overflows) setState(() => _overflows = overflows);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final command = input["command"] as String? ?? "";
|
||||
final command = widget.input["command"] as String? ?? "";
|
||||
final hasResult = widget.result != null && widget.result!.isNotEmpty;
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Bash",
|
||||
icon: LucideIcons.terminal,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: command,
|
||||
final theme = Theme.of(context);
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
|
||||
final s = HSLColor.fromColor(theme.colorScheme.border).saturation;
|
||||
|
||||
final termBg = dark
|
||||
? HSLColor.fromAHSL(1, h, s.clamp(0, 0.35), 0.10).toColor()
|
||||
: HSLColor.fromAHSL(1, h, s.clamp(0, 0.30), 0.92).toColor();
|
||||
|
||||
final termTitleBg = dark
|
||||
? HSLColor.fromAHSL(1, h, s.clamp(0, 0.35), 0.14).toColor()
|
||||
: HSLColor.fromAHSL(1, h, s.clamp(0, 0.30), 0.87).toColor();
|
||||
|
||||
final termBorder = theme.colorScheme.border;
|
||||
final outputGray = theme.colorScheme.mutedForeground;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: termBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: termBorder, width: 1),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
// title bar
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: termTitleBg,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: termBorder, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"Terminal",
|
||||
style: theme.typography.p.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (hasResult)
|
||||
GestureDetector(
|
||||
onTap: () => _openFullscreen(context, command, termBg, termTitleBg, termBorder, outputGray),
|
||||
child: Icon(
|
||||
LucideIcons.maximize2,
|
||||
size: 13,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// prompt always visible
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 6),
|
||||
child: _TerminalPrompt(
|
||||
command: command,
|
||||
cwd: widget.input["cwd"] as String? ??
|
||||
context.read<ChatProvider>().workingDirectory,
|
||||
),
|
||||
),
|
||||
|
||||
// output — shrinks to content, caps at maxHeight, gradient only when overflowing
|
||||
if (hasResult)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: ShaderMask(
|
||||
shaderCallback: (rect) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_overflows ? Colors.transparent : Colors.white,
|
||||
Colors.white,
|
||||
],
|
||||
stops: const [0.0, 0.15],
|
||||
).createShader(rect),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: SelectableText(
|
||||
widget.result!,
|
||||
style: theme.typography.mono.copyWith(
|
||||
color: outputGray,
|
||||
fontSize: 11,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!hasResult) const SizedBox(height: 10),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (widget.pendingPermission != null) ...[
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text("Don't ask again for:").xSmall,
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ruleController,
|
||||
style: const TextStyle(fontSize: 12, fontFamily: "monospace"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(
|
||||
PermissionDecision.allowAlways,
|
||||
persistRule: _ruleController.text.trim(),
|
||||
),
|
||||
child: Text("Yes, don't ask again").small,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _TerminalPrompt extends StatelessWidget {
|
||||
const _TerminalPrompt({required this.command, this.cwd});
|
||||
final String command;
|
||||
final String? cwd;
|
||||
|
||||
String _shortCwd() {
|
||||
try {
|
||||
final home = Platform.environment["HOME"] ?? "";
|
||||
var resolved = cwd ?? Directory.current.path;
|
||||
if (home.isNotEmpty && resolved.startsWith(home)) {
|
||||
resolved = "~${resolved.substring(home.length)}";
|
||||
}
|
||||
return resolved;
|
||||
} catch (_) {
|
||||
return "~";
|
||||
}
|
||||
}
|
||||
|
||||
String _hostname() {
|
||||
try {
|
||||
return Platform.localHostname.split(".").first;
|
||||
} catch (_) {
|
||||
return "localhost";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hostname = _hostname();
|
||||
final cwd = _shortCwd();
|
||||
final theme = Theme.of(context);
|
||||
final monoBase = theme.typography.mono.copyWith(
|
||||
fontSize: 11,
|
||||
height: 1.25,
|
||||
);
|
||||
|
||||
final fg = theme.colorScheme.foreground;
|
||||
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: monoBase,
|
||||
children: [
|
||||
|
||||
// the_agency@hostname — green
|
||||
TextSpan(
|
||||
text: "the_agency@$hostname",
|
||||
style: const TextStyle(color: Color(0xFF4EC94E), fontWeight: FontWeight.w600),
|
||||
),
|
||||
|
||||
TextSpan(text: ":", style: TextStyle(color: fg)),
|
||||
|
||||
// cwd — blue
|
||||
TextSpan(
|
||||
text: cwd,
|
||||
style: const TextStyle(color: Color(0xFF5BB8FF)),
|
||||
),
|
||||
|
||||
TextSpan(text: "\$ ", style: TextStyle(color: fg)),
|
||||
|
||||
TextSpan(text: command, style: TextStyle(color: fg)),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Dot extends StatelessWidget {
|
||||
const _Dot({required this.color});
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class DefaultToolBubble extends StatelessWidget {
|
||||
@@ -8,13 +9,13 @@ class DefaultToolBubble extends StatelessWidget {
|
||||
required this.toolName,
|
||||
this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,7 +25,7 @@ class DefaultToolBubble extends StatelessWidget {
|
||||
toolName: toolName,
|
||||
icon: LucideIcons.wrench,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
body: input != null && input!.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
@@ -10,12 +11,12 @@ class EditBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -28,7 +29,7 @@ class EditBubble extends StatelessWidget {
|
||||
toolName: "Edit",
|
||||
icon: LucideIcons.filePen,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: oldString,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
@@ -9,12 +10,12 @@ class GlobBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,7 +31,7 @@ class GlobBubble extends StatelessWidget {
|
||||
toolName: "Glob",
|
||||
icon: LucideIcons.folderSearch,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
@@ -9,12 +10,12 @@ class GrepBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,7 +31,7 @@ class GrepBubble extends StatelessWidget {
|
||||
toolName: "Grep",
|
||||
icon: LucideIcons.search,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
@@ -9,12 +10,12 @@ class ReadBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -25,7 +26,7 @@ class ReadBubble extends StatelessWidget {
|
||||
toolName: "Read",
|
||||
icon: LucideIcons.fileText,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
|
||||
class ToolBubbleBase extends StatelessWidget {
|
||||
class ToolBubbleBase extends StatefulWidget {
|
||||
const ToolBubbleBase({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
@@ -11,7 +11,7 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
this.detail,
|
||||
this.body,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
@@ -19,17 +19,77 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
final String? detail;
|
||||
final Widget? body;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
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(
|
||||
@@ -47,24 +107,17 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon).iconSmall,
|
||||
|
||||
Icon(widget.icon).iconSmall,
|
||||
Gap(8),
|
||||
|
||||
Text(
|
||||
toolName,
|
||||
).textSmall,
|
||||
|
||||
Text(widget.toolName).textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
|
||||
if (detail != null)...[
|
||||
if (widget.detail != null)...[
|
||||
Gap(16),
|
||||
Text(
|
||||
detail!,
|
||||
).mono.xSmall
|
||||
Text(widget.detail!).mono.xSmall
|
||||
]
|
||||
|
||||
],
|
||||
@@ -72,14 +125,12 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
),
|
||||
|
||||
|
||||
|
||||
if (body != null) ...[
|
||||
if (widget.body != null) ...[
|
||||
Divider(),
|
||||
|
||||
body!,
|
||||
widget.body!,
|
||||
],
|
||||
|
||||
if (result != null) ...[
|
||||
if (widget.result != null) ...[
|
||||
Divider(),
|
||||
|
||||
Padding(
|
||||
@@ -88,7 +139,7 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
vertical: 8,
|
||||
),
|
||||
child: SelectableText(
|
||||
"\u200B${result!}",
|
||||
"\u200B${widget.result!}",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
@@ -96,50 +147,253 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isPendingPermission) ...[
|
||||
|
||||
if (pp != null) ...[
|
||||
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
|
||||
child: Text("Yes, for this session").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── 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,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebFetchBubble extends StatelessWidget {
|
||||
@@ -6,12 +7,12 @@ class WebFetchBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +22,7 @@ class WebFetchBubble extends StatelessWidget {
|
||||
toolName: "WebFetch",
|
||||
icon: LucideIcons.link,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: url,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebSearchBubble extends StatelessWidget {
|
||||
@@ -6,12 +7,12 @@ class WebSearchBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +22,7 @@ class WebSearchBubble extends StatelessWidget {
|
||||
toolName: "WebSearch",
|
||||
icon: LucideIcons.globe,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: query,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
@@ -10,12 +11,12 @@ class WriteBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -27,7 +28,7 @@ class WriteBubble extends StatelessWidget {
|
||||
toolName: "Write",
|
||||
icon: LucideIcons.filePlus,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: "",
|
||||
|
||||
Reference in New Issue
Block a user