Add command files and enhance session management features
This commit is contained in:
@@ -2,12 +2,24 @@ import "package:gpt_markdown/gpt_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AssistantBubble extends StatelessWidget {
|
||||
const AssistantBubble({super.key, required this.content});
|
||||
const AssistantBubble({super.key, required this.content, this.isStreaming = false});
|
||||
|
||||
final String content;
|
||||
final bool isStreaming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
if (isStreaming) {
|
||||
return SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GptMarkdown(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision, PendingPermission;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../src/permissions/permission_types.dart";
|
||||
import "tools/advisor_bubble.dart";
|
||||
import "tools/bash_bubble.dart";
|
||||
import "tools/default_tool_bubble.dart";
|
||||
@@ -17,13 +18,13 @@ class ToolBubble extends StatelessWidget {
|
||||
required this.toolName,
|
||||
this.toolInput,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? toolInput;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
// parse a tool message content string into (toolName, toolInput)
|
||||
// format: "$toolName call\n{json}" or "$toolName result\n..."
|
||||
@@ -41,7 +42,11 @@ class ToolBubble extends StatelessWidget {
|
||||
|
||||
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(rest);
|
||||
// streamed output may be appended after the json block, so only
|
||||
// decode up to the closing brace — not the whole rest string
|
||||
final jsonEnd = _findJsonEnd(rest);
|
||||
final jsonStr = jsonEnd != -1 ? rest.substring(0, jsonEnd + 1) : rest;
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return (name, decoded);
|
||||
}
|
||||
@@ -51,6 +56,27 @@ class ToolBubble extends StatelessWidget {
|
||||
return (name, null);
|
||||
}
|
||||
|
||||
// find the index of the closing } that ends the top-level json object
|
||||
static int _findJsonEnd(String s) {
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
for (int i = 0; i < s.length; i++) {
|
||||
final c = s[i];
|
||||
if (inString) {
|
||||
if (c == "\\" ) { i++; continue; } // skip escaped char
|
||||
if (c == "\"") inString = false;
|
||||
} else {
|
||||
if (c == "\"") inString = true;
|
||||
else if (c == "{") depth++;
|
||||
else if (c == "}") {
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static String _extractName(String line) {
|
||||
// strip trailing " call" or " result"
|
||||
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
|
||||
@@ -64,26 +90,26 @@ class ToolBubble extends StatelessWidget {
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return BashBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Edit":
|
||||
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return EditBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Read":
|
||||
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return ReadBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Write":
|
||||
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return WriteBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Glob":
|
||||
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return GlobBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Grep":
|
||||
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return GrepBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "WebSearch":
|
||||
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return WebSearchBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "WebFetch":
|
||||
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return WebFetchBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Advisor":
|
||||
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return AdvisorBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
|
||||
default:
|
||||
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
|
||||
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, pendingPermission: pendingPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -1,24 +1,68 @@
|
||||
import "dart:typed_data";
|
||||
import "package:clawd_code/ui/widgets/common/button.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "../../../models/attachment.dart";
|
||||
import "../attachment_preview.dart";
|
||||
import "../../../../src/session/session_types.dart";
|
||||
|
||||
class UserBubble extends StatelessWidget {
|
||||
Color _msgColour(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
|
||||
return dark
|
||||
? HSLColor.fromAHSL(1, h, 0.25, 0.25).toColor()
|
||||
: HSLColor.fromAHSL(1, h, 0.30, 0.6).toColor();
|
||||
}
|
||||
|
||||
class UserBubble extends StatefulWidget {
|
||||
const UserBubble({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.attachments,
|
||||
this.timestamp,
|
||||
this.onRetry,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
final String content;
|
||||
final List<MessageAttachment>? attachments;
|
||||
final DateTime? timestamp;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
State<UserBubble> createState() => _UserBubbleState();
|
||||
}
|
||||
|
||||
class _UserBubbleState extends State<UserBubble> {
|
||||
bool _hovered = false;
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
final now = DateTime.now();
|
||||
final h = local.hour.toString().padLeft(2, "0");
|
||||
final m = local.minute.toString().padLeft(2, "0");
|
||||
final time = "$h:$m";
|
||||
|
||||
final isToday = local.year == now.year && local.month == now.month && local.day == now.day;
|
||||
if (isToday) return "Today at $time";
|
||||
|
||||
// show date for anything older
|
||||
final day = local.day.toString().padLeft(2, "0");
|
||||
final month = local.month.toString().padLeft(2, "0");
|
||||
return "${local.year == now.year ? "" : "${local.year}/"}$month/$day at $time";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final atts = attachments;
|
||||
final atts = widget.attachments;
|
||||
final muted = Theme.of(context).colorScheme.mutedForeground;
|
||||
|
||||
return Align(
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -49,13 +93,97 @@ class UserBubble extends StatelessWidget {
|
||||
],
|
||||
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
backgroundColor: Theme.of(context).colorScheme.border,
|
||||
child: SelectableText(content),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
borderColor: _msgColour(context).scaleAlpha(0.9),
|
||||
backgroundColor: _msgColour(context),
|
||||
boxShadow: [],
|
||||
child: SelectableText(widget.content),
|
||||
),
|
||||
|
||||
const Gap(6),
|
||||
|
||||
AnimatedOpacity(
|
||||
opacity: _hovered ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
if (widget.timestamp != null)
|
||||
Text(
|
||||
_formatTime(widget.timestamp!),
|
||||
style: TextStyle(color: muted),
|
||||
).xSmall,
|
||||
|
||||
const Gap(10),
|
||||
|
||||
_ActionBtn(
|
||||
icon: LucideIcons.refreshCw,
|
||||
tooltip: "Retry",
|
||||
onTap: widget.onRetry,
|
||||
color: muted,
|
||||
),
|
||||
_ActionBtn(
|
||||
icon: LucideIcons.pencil,
|
||||
tooltip: "Edit",
|
||||
onTap: widget.onEdit,
|
||||
color: muted,
|
||||
),
|
||||
_ActionBtn(
|
||||
icon: LucideIcons.copy,
|
||||
tooltip: "Copy",
|
||||
color: muted,
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: widget.content));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _ActionBtn extends StatelessWidget {
|
||||
const _ActionBtn({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
if (true) {
|
||||
return IconButton.ghost(
|
||||
onPressed: () {
|
||||
if (onTap != null) onTap!();
|
||||
},
|
||||
icon: Icon(
|
||||
icon,
|
||||
).iconSmall.iconMutedForeground,
|
||||
);
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
tooltip: TooltipContainer(child: Text(tooltip)),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: Icon(icon, size: 14, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user