feat: redesign chat UI from bubble style to timeline workflow

Replace traditional chat bubble layout with a Claude Code-inspired
timeline/workflow design:
- Vertical gray line connecting sequential event nodes
- Colored dots for each event (green=done, red=error, yellow=warning)
- Animated spinning asterisk (*) on active nodes
- Streaming text with blinking cursor in timeline nodes
- Tool execution shown as code blocks within timeline
- User prompts as distinct nodes with person icon

New file: timeline_event_node.dart (TimelineEventNode, CodeBlock)
Rewritten: chat_page.dart (timeline layout, no more bubbles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 17:33:42 -08:00
parent 74be945e4a
commit 20325a84bd
2 changed files with 468 additions and 346 deletions

View File

@ -3,15 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_colors.dart';
import '../../domain/entities/chat_message.dart';
import '../providers/chat_providers.dart';
import '../widgets/message_bubble.dart';
import '../widgets/tool_execution_card.dart';
import '../widgets/approval_action_card.dart';
import '../widgets/agent_thinking_indicator.dart';
import '../widgets/timeline_event_node.dart';
import '../widgets/stream_text_widget.dart';
import '../widgets/approval_action_card.dart';
import '../../../agent_call/presentation/pages/agent_call_page.dart';
// ---------------------------------------------------------------------------
// Chat page ConsumerStatefulWidget
// Chat page Timeline workflow style (inspired by Claude Code VSCode)
// ---------------------------------------------------------------------------
class ChatPage extends ConsumerStatefulWidget {
@ -53,120 +51,127 @@ class _ChatPageState extends ConsumerState<ChatPage> {
);
}
// -- Message widget dispatch -----------------------------------------------
// -- Timeline node builder ------------------------------------------------
Widget _buildMessageWidget(ChatMessage message, ChatState chatState) {
Widget _buildTimelineNode(
ChatMessage message,
ChatState chatState, {
required bool isFirst,
required bool isLast,
}) {
final isLastMessage = chatState.messages.last.id == message.id;
final isStreamingNow = isLastMessage && chatState.isStreaming;
// User message special "prompt" node
if (message.role == MessageRole.user) {
return TimelineEventNode(
status: NodeStatus.completed,
label: message.content,
isFirst: isFirst,
isLast: isLast,
icon: Icons.person_outline,
);
}
switch (message.type) {
case MessageType.toolUse:
case MessageType.toolResult:
if (message.toolExecution != null) {
return ToolExecutionCard(toolExecution: message.toolExecution!);
}
return MessageBubble(message: message);
case MessageType.approval:
if (message.approvalRequest != null) {
return ApprovalActionCard(
approvalRequest: message.approvalRequest!,
onApprove: () => ref.read(chatProvider.notifier)
.approveCommand(message.approvalRequest!.taskId),
onReject: (reason) => ref.read(chatProvider.notifier)
.rejectCommand(message.approvalRequest!.taskId, reason: reason),
);
}
return MessageBubble(message: message);
case MessageType.standingOrderDraft:
return _StandingOrderDraftCard(
draft: message.metadata ?? {},
onConfirm: () => ref.read(chatProvider.notifier)
.confirmStandingOrder(message.metadata ?? {}),
case MessageType.thinking:
return TimelineEventNode(
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
label: '思考中...',
isFirst: isFirst,
isLast: isLast,
content: StreamTextWidget(
text: message.content,
isStreaming: isStreamingNow,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
);
case MessageType.thinking:
// AI thinking content italic label + streaming text
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'思考中...',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 4),
StreamTextWidget(
text: message.content,
isStreaming: isStreamingNow,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
],
),
case MessageType.toolUse:
final tool = message.toolExecution;
final toolName = tool?.toolName ?? 'unknown';
final isExecuting = tool?.status == ToolStatus.executing;
return TimelineEventNode(
status: isExecuting ? NodeStatus.active : NodeStatus.completed,
label: toolName,
isFirst: isFirst,
isLast: isLast,
icon: isExecuting ? null : Icons.check_circle_outline,
content: tool != null && tool.input.isNotEmpty
? CodeBlock(text: tool.input)
: null,
);
case MessageType.toolResult:
final tool = message.toolExecution;
final isError = tool?.status == ToolStatus.error;
return TimelineEventNode(
status: isError ? NodeStatus.error : NodeStatus.completed,
label: isError ? '执行失败' : '执行结果',
isFirst: isFirst,
isLast: isLast,
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
content: tool?.output != null && tool!.output!.isNotEmpty
? CodeBlock(
text: tool.output!,
textColor: isError ? AppColors.error : null,
)
: null,
);
case MessageType.approval:
return TimelineEventNode(
status: NodeStatus.warning,
label: '需要审批',
isFirst: isFirst,
isLast: isLast,
icon: Icons.shield_outlined,
content: message.approvalRequest != null
? ApprovalActionCard(
approvalRequest: message.approvalRequest!,
onApprove: () => ref.read(chatProvider.notifier)
.approveCommand(message.approvalRequest!.taskId),
onReject: (reason) => ref.read(chatProvider.notifier)
.rejectCommand(message.approvalRequest!.taskId, reason: reason),
)
: null,
);
case MessageType.standingOrderDraft:
return TimelineEventNode(
status: NodeStatus.warning,
label: '常驻指令草案',
isFirst: isFirst,
isLast: isLast,
icon: Icons.schedule,
content: _StandingOrderContent(
draft: message.metadata ?? {},
onConfirm: () => ref.read(chatProvider.notifier)
.confirmStandingOrder(message.metadata ?? {}),
),
);
case MessageType.text:
default:
// User messages standard bubble
if (message.role == MessageRole.user) {
return MessageBubble(message: message);
}
// AI text messages use StreamTextWidget when actively streaming
if (isStreamingNow) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: StreamTextWidget(
text: message.content,
isStreaming: true,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
),
),
return TimelineEventNode(
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
label: isStreamingNow ? '回复中...' : '回复',
isFirst: isFirst,
isLast: isLast,
icon: isStreamingNow ? null : Icons.check_circle_outline,
content: StreamTextWidget(
text: message.content,
isStreaming: isStreamingNow,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
),
);
}
return MessageBubble(message: message);
),
);
}
}
@ -181,7 +186,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return Scaffold(
appBar: AppBar(
title: const Text('AI 对话'),
title: const Text('iAgent'),
actions: [
// Stop button during streaming
if (chatState.isStreaming)
@ -208,67 +213,43 @@ class _ChatPageState extends ConsumerState<ChatPage> {
children: [
// Error banner
if (chatState.error != null)
MaterialBanner(
content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)),
backgroundColor: AppColors.error.withOpacity(0.1),
actions: [
TextButton(
onPressed: () =>
ref.read(chatProvider.notifier).clearChat(),
child: const Text('关闭'),
),
],
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: AppColors.error.withOpacity(0.1),
child: Row(
children: [
const Icon(Icons.error_outline, size: 16, color: AppColors.error),
const SizedBox(width: 8),
Expanded(
child: Text(
chatState.error!,
style: const TextStyle(color: AppColors.error, fontSize: 13),
),
),
GestureDetector(
onTap: () => ref.read(chatProvider.notifier).clearChat(),
child: const Icon(Icons.close, size: 16, color: AppColors.error),
),
],
),
),
// Agent status bar
if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error)
_AgentStatusBar(status: chatState.agentStatus),
// Message list
// Timeline message list
Expanded(
child: chatState.messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted),
const SizedBox(height: 16),
Text(
'开始与 iAgent 对话',
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'输入文字或拨打语音通话',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: _openVoiceCall,
icon: const Icon(Icons.call),
label: const Text('语音通话'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.success,
side: const BorderSide(color: AppColors.success),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
)
? _buildEmptyState()
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
itemCount: chatState.messages.length +
(chatState.agentStatus == AgentStatus.thinking ? 1 : 0),
itemCount: chatState.messages.length,
itemBuilder: (context, index) {
// Trailing thinking indicator
if (index == chatState.messages.length) {
return const AgentThinkingIndicator();
}
return _buildMessageWidget(
return _buildTimelineNode(
chatState.messages[index],
chatState,
isFirst: index == 0,
isLast: index == chatState.messages.length - 1 &&
!chatState.isStreaming,
);
},
),
@ -281,8 +262,41 @@ class _ChatPageState extends ConsumerState<ChatPage> {
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted),
const SizedBox(height: 16),
Text(
'开始与 iAgent 对话',
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'输入指令或拨打语音通话',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: _openVoiceCall,
icon: const Icon(Icons.call),
label: const Text('语音通话'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.success,
side: const BorderSide(color: AppColors.success),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
}
Widget _buildInputArea(ChatState chatState) {
final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval;
final isDisabled = chatState.isStreaming ||
chatState.agentStatus == AgentStatus.awaitingApproval;
return Container(
padding: const EdgeInsets.all(8),
@ -291,48 +305,35 @@ class _ChatPageState extends ConsumerState<ChatPage> {
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
child: Row(
children: [
if (chatState.agentStatus == AgentStatus.awaitingApproval)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'等待审批中...',
style: TextStyle(color: AppColors.warning, fontSize: 12),
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: '输入指令...',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
enabled: !isDisabled,
),
Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: '向 iAgent 提问...',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
enabled: !isDisabled,
),
),
const SizedBox(width: 8),
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
)
else
IconButton(
icon: const Icon(Icons.send),
onPressed: isDisabled ? null : _send,
),
],
),
const SizedBox(width: 8),
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
)
else
IconButton(
icon: const Icon(Icons.send),
onPressed: isDisabled ? null : _send,
),
],
),
),
@ -348,57 +349,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
// ---------------------------------------------------------------------------
// Agent status bar
// Standing order content (embedded in timeline node)
// ---------------------------------------------------------------------------
class _AgentStatusBar extends StatelessWidget {
final AgentStatus status;
const _AgentStatusBar({required this.status});
@override
Widget build(BuildContext context) {
final (String label, Color color, IconData icon) = switch (status) {
AgentStatus.thinking => ('AI 正在思考...', AppColors.info, Icons.psychology),
AgentStatus.executing => ('正在执行命令...', AppColors.primary, Icons.terminal),
AgentStatus.awaitingApproval => ('等待审批', AppColors.warning, Icons.shield_outlined),
_ => ('', AppColors.textMuted, Icons.info_outline),
};
if (label.isEmpty) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: color.withOpacity(0.1),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: status == AgentStatus.executing
? CircularProgressIndicator(strokeWidth: 2, color: color)
: Icon(icon, size: 16, color: color),
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Standing order draft card
// ---------------------------------------------------------------------------
class _StandingOrderDraftCard extends StatelessWidget {
class _StandingOrderContent extends StatelessWidget {
final Map<String, dynamic> draft;
final VoidCallback onConfirm;
const _StandingOrderDraftCard({
const _StandingOrderContent({
required this.draft,
required this.onConfirm,
});
@ -410,112 +368,57 @@ class _StandingOrderDraftCard extends StatelessWidget {
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.primary.withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(Icons.schedule, size: 18, color: AppColors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'常驻指令草案',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: AppColors.primary,
),
),
),
],
),
const SizedBox(height: 10),
// Name
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
if (cron.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(cron, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace')),
],
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
if (cron.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
cron,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
fontFamily: 'monospace',
),
],
if (command.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
command,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary),
),
),
],
if (targets.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.dns, size: 14, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(
'目标: ${targets.join(", ")}',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
),
],
),
],
const SizedBox(height: 12),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () {
// Dismiss no specific API call needed
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('取消', style: TextStyle(fontSize: 13)),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('确认', style: TextStyle(fontSize: 13)),
),
],
),
],
),
if (command.isNotEmpty) ...[
const SizedBox(height: 6),
CodeBlock(text: command),
],
if (targets.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'目标: ${targets.join(", ")}',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
),
],
const SizedBox(height: 8),
Row(
children: [
OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
minimumSize: Size.zero,
),
child: const Text('取消', style: TextStyle(fontSize: 12)),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
minimumSize: Size.zero,
),
child: const Text('确认', style: TextStyle(fontSize: 12)),
),
],
),
],
);
}
}

View File

@ -0,0 +1,219 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
/// Status of a timeline node, determines dot style and color.
enum NodeStatus { active, completed, error, warning, idle }
/// A single node in the agent workflow timeline.
///
/// Renders: vertical line | colored dot/spinner | content
/// When [status] is [NodeStatus.active], the dot becomes an animated spinner.
class TimelineEventNode extends StatelessWidget {
final NodeStatus status;
final String label;
final Widget? content;
final bool isFirst;
final bool isLast;
final bool isStreaming;
final IconData? icon;
const TimelineEventNode({
super.key,
required this.status,
required this.label,
this.content,
this.isFirst = false,
this.isLast = false,
this.isStreaming = false,
this.icon,
});
Color get _dotColor => switch (status) {
NodeStatus.active => AppColors.info,
NodeStatus.completed => AppColors.success,
NodeStatus.error => AppColors.error,
NodeStatus.warning => AppColors.warning,
NodeStatus.idle => AppColors.textMuted,
};
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left column: vertical line + dot
SizedBox(
width: 28,
child: Column(
children: [
// Line above dot
Container(
width: 1.5,
height: 8,
color: isFirst ? Colors.transparent : AppColors.surfaceLight,
),
// Dot or spinner
status == NodeStatus.active
? _SpinnerDot(color: _dotColor)
: _StaticDot(color: _dotColor, icon: icon),
// Line below dot
Expanded(
child: Container(
width: 1.5,
color: isLast ? Colors.transparent : AppColors.surfaceLight,
),
),
],
),
),
const SizedBox(width: 8),
// Right column: label + content
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Label row
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
label,
style: TextStyle(
color: status == NodeStatus.active
? _dotColor
: AppColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
// Content
if (content != null) ...[
const SizedBox(height: 6),
content!,
],
],
),
),
),
],
),
);
}
}
/// Static colored dot with optional icon for completed/error/idle states.
class _StaticDot extends StatelessWidget {
final Color color;
final IconData? icon;
const _StaticDot({required this.color, this.icon});
@override
Widget build(BuildContext context) {
if (icon != null) {
return SizedBox(
width: 16,
height: 16,
child: Icon(icon, size: 14, color: color),
);
}
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
);
}
}
/// Animated spinning asterisk for active nodes.
class _SpinnerDot extends StatefulWidget {
final Color color;
const _SpinnerDot({required this.color});
@override
State<_SpinnerDot> createState() => _SpinnerDotState();
}
class _SpinnerDotState extends State<_SpinnerDot>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (_, __) {
return Transform.rotate(
angle: _controller.value * 2 * math.pi,
child: Text(
'*',
style: TextStyle(
color: widget.color,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1,
),
),
);
},
);
}
}
/// Code block container used for tool input/output.
class CodeBlock extends StatelessWidget {
final String text;
final Color? textColor;
final int? maxLines;
const CodeBlock({
super.key,
required this.text,
this.textColor,
this.maxLines,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(6),
),
child: SelectableText(
text,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: textColor ?? AppColors.textSecondary,
height: 1.4,
),
maxLines: maxLines,
),
);
}
}