397 lines
12 KiB
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,
|
|
),
|
|
);
|
|
}
|
|
}
|