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 input; final String? result; final PendingPermission? pendingPermission; @override State createState() => _BashBubbleState(); } class _BashBubbleState extends State { 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().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().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().resolvePermission(PermissionDecision.allowOnce), child: Text("Yes").small, ), ), const SizedBox(width: 8), Expanded( child: Button.outline( leading: Icon(LucideIcons.checkCheck).iconSmall, onPressed: () => context.read().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().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, ), ); } }