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

397 lines
12 KiB
Dart

import "dart:io";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "package:provider/provider.dart";
import "../../../../providers/chat_provider.dart";
import "../permission_decision.dart";
import "../../../common/pane_dialog.dart";
class BashBubble extends StatefulWidget {
const BashBubble({
super.key,
required this.input,
this.result,
this.pendingPermission,
});
final Map<String, dynamic> input;
final String? result;
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 = widget.input["command"] as String? ?? "";
final hasResult = widget.result != null && widget.result!.isNotEmpty;
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,
),
);
}
}