refactor: reasoning parser

This commit is contained in:
cogwheel0
2025-09-05 22:44:04 +05:30
parent 2580bf961e
commit f8650cf809
3 changed files with 454 additions and 173 deletions

View File

@@ -0,0 +1,24 @@
import 'tool_calls_parser.dart';
import 'reasoning_parser.dart';
/// Unified segment representing ordered pieces of a message:
/// - `text`: plain text/markdown to render
/// - `toolCall`: a parsed tool call entry to render as a tile
/// - `reasoning`: a parsed reasoning entry to render as a tile
class MessageSegment {
final String? text;
final ToolCallEntry? toolCall;
final ReasoningEntry? reasoning;
const MessageSegment._({this.text, this.toolCall, this.reasoning});
factory MessageSegment.text(String text) => MessageSegment._(text: text);
factory MessageSegment.tool(ToolCallEntry tool) =>
MessageSegment._(toolCall: tool);
factory MessageSegment.reason(ReasoningEntry entry) =>
MessageSegment._(reasoning: entry);
bool get isText => text != null;
bool get isTool => toolCall != null;
bool get isReasoning => reasoning != null;
}

View File

@@ -100,6 +100,203 @@ class ReasoningParser {
return null; return null;
} }
/// Splits content into ordered segments of plain text and reasoning entries
/// (in the order they appear). Supports multiple reasoning blocks.
/// - Handles `<details type="reasoning" ...>` with optional summary/duration/done
/// - Handles raw tag pairs like `<think>...</think>` and `<reasoning>...</reasoning>`
/// - Handles incomplete/streaming cases by emitting a partial reasoning entry
static List<ReasoningSegment>? segments(
String content, {
List<String>? customTagPair,
bool detectDefaultTags = true,
}) {
if (content.isEmpty) return null;
// Build raw tag pairs to check
final tagPairs = <List<String>>[];
if (customTagPair != null && customTagPair.length == 2) {
tagPairs.add(customTagPair);
}
if (detectDefaultTags) {
tagPairs.addAll(defaultReasoningTagPairs);
}
final segs = <ReasoningSegment>[];
int index = 0;
while (index < content.length) {
final nextDetails = content.indexOf('<details', index);
// Find earliest raw tag start among known pairs
int nextRawStart = -1;
List<String>? rawPair; // [start, end]
for (final pair in tagPairs) {
final s = content.indexOf(pair[0], index);
if (s != -1 && (nextRawStart == -1 || s < nextRawStart)) {
nextRawStart = s;
rawPair = pair;
}
}
// Determine which comes first: reasoning <details> or raw tag
int nextIdx;
String kind; // 'details' or 'raw' or 'none'
if (nextDetails == -1 && nextRawStart == -1) {
nextIdx = -1;
kind = 'none';
} else if (nextDetails != -1 && (nextRawStart == -1 || nextDetails < nextRawStart)) {
nextIdx = nextDetails;
kind = 'details';
} else {
nextIdx = nextRawStart;
kind = 'raw';
}
if (kind == 'none') {
if (index < content.length) {
segs.add(ReasoningSegment.text(content.substring(index)));
}
break;
}
// Add text before the next block
if (nextIdx > index) {
segs.add(ReasoningSegment.text(content.substring(index, nextIdx)));
}
if (kind == 'details') {
// Try to parse the opening <details ...>
final openEnd = content.indexOf('>', nextIdx);
if (openEnd == -1) {
// Malformed tag; treat rest as text and stop
segs.add(ReasoningSegment.text(content.substring(nextIdx)));
break;
}
final openTag = content.substring(nextIdx, openEnd + 1);
// Parse attributes
final attrs = <String, String>{};
final attrRegex = RegExp(r'(\w+)="(.*?)"');
for (final m in attrRegex.allMatches(openTag)) {
attrs[m.group(1)!] = m.group(2) ?? '';
}
final isReasoning = (attrs['type'] ?? '') == 'reasoning';
// Find matching closing tag with nesting awareness
int depth = 1;
int i = openEnd + 1;
while (i < content.length && depth > 0) {
final nextOpen = content.indexOf('<details', i);
final nextClose = content.indexOf('</details>', i);
if (nextClose == -1 && nextOpen == -1) break;
if (nextOpen != -1 && (nextClose == -1 || nextOpen < nextClose)) {
depth++;
i = nextOpen + 8; // '<details'
} else {
depth--;
i = (nextClose != -1) ? nextClose + 10 : content.length;
}
}
if (!isReasoning) {
// Not a reasoning block; keep entire block as text if closed,
// else append remainder and stop (streaming/malformed)
if (depth != 0) {
segs.add(ReasoningSegment.text(content.substring(nextIdx)));
break;
} else {
final full = content.substring(nextIdx, i);
segs.add(ReasoningSegment.text(full));
index = i;
continue;
}
}
// Reasoning block
final done = (attrs['done'] ?? 'true') == 'true';
final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0;
if (depth != 0) {
// Unclosed; treat as streaming partial
final after = content.substring(openEnd + 1);
final summaryMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(after);
final summary = (summaryMatch?.group(1) ?? '').trim();
final reasoning = after
.replaceAll(RegExp(r'^\s*<summary>[\s\S]*?<\/summary>'), '')
.trim();
segs.add(
ReasoningSegment.entry(
ReasoningEntry(
reasoning: reasoning,
summary: summary,
duration: duration,
isDone: false,
),
),
);
// No more content after partial block
break;
} else {
// Closed block: extract inner content
final inner = content.substring(openEnd + 1, i - 10); // without </details>
final sumMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(inner);
final summary = (sumMatch?.group(1) ?? '').trim();
final reasoning = inner
.replaceAll(RegExp(r'<summary>[\s\S]*?<\/summary>'), '')
.trim();
segs.add(
ReasoningSegment.entry(
ReasoningEntry(
reasoning: reasoning,
summary: summary,
duration: duration,
isDone: done,
),
),
);
index = i;
continue;
}
} else if (kind == 'raw' && rawPair != null) {
final startTag = rawPair[0];
final endTag = rawPair[1];
final start = nextIdx;
final end = content.indexOf(endTag, start + startTag.length);
if (end == -1) {
// Unclosed raw tag => streaming partial
final inner = content.substring(start + startTag.length);
segs.add(
ReasoningSegment.entry(
ReasoningEntry(
reasoning: inner.trim(),
summary: '',
duration: 0,
isDone: false,
),
),
);
break;
} else {
final inner = content.substring(start + startTag.length, end);
segs.add(
ReasoningSegment.entry(
ReasoningEntry(
reasoning: inner.trim(),
summary: '',
duration: 0,
isDone: true,
),
),
);
index = end + endTag.length;
continue;
}
}
}
return segs.isEmpty ? null : segs;
}
/// Checks if a message contains reasoning content /// Checks if a message contains reasoning content
static bool hasReasoningContent(String content) { static bool hasReasoningContent(String content) {
if (content.contains('<details type="reasoning"')) return true; if (content.contains('<details type="reasoning"')) return true;
@@ -176,3 +373,41 @@ class ReasoningContent {
.trim(); .trim();
} }
} }
/// Lightweight reasoning block for segmented rendering
class ReasoningEntry {
final String reasoning;
final String summary;
final int duration;
final bool isDone;
const ReasoningEntry({
required this.reasoning,
required this.summary,
required this.duration,
required this.isDone,
});
String get formattedDuration => ReasoningParser.formatDuration(duration);
String get cleanedReasoning {
return reasoning
.split('\n')
.map((line) => line.startsWith('>') ? line.substring(1).trim() : line)
.join('\n')
.trim();
}
}
/// Ordered segment that is either plain text or a reasoning entry
class ReasoningSegment {
final String? text;
final ReasoningEntry? entry;
const ReasoningSegment._({this.text, this.entry});
factory ReasoningSegment.text(String text) => ReasoningSegment._(text: text);
factory ReasoningSegment.entry(ReasoningEntry entry) =>
ReasoningSegment._(entry: entry);
bool get isReasoning => entry != null;
}

View File

@@ -8,6 +8,7 @@ import 'dart:io' show Platform;
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../shared/widgets/markdown/streaming_markdown_widget.dart';
import '../../../core/utils/reasoning_parser.dart'; import '../../../core/utils/reasoning_parser.dart';
import '../../../core/utils/message_segments.dart';
import '../../../core/utils/tool_calls_parser.dart'; import '../../../core/utils/tool_calls_parser.dart';
import 'enhanced_image_attachment.dart'; import 'enhanced_image_attachment.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
@@ -41,14 +42,13 @@ class AssistantMessageWidget extends ConsumerStatefulWidget {
class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget> class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
with TickerProviderStateMixin { with TickerProviderStateMixin {
bool _showReasoning = false;
late AnimationController _fadeController; late AnimationController _fadeController;
late AnimationController _slideController; late AnimationController _slideController;
ReasoningContent? _reasoningContent; // Unified content segments (text, tool-calls, reasoning)
List<ToolCallsSegment> _toolSegments = const []; List<MessageSegment> _segments = const [];
final Set<String> _expandedToolIds = {}; final Set<String> _expandedToolIds = {};
final Set<int> _expandedReasoning = {};
Widget? _cachedAvatar; Widget? _cachedAvatar;
String _contentSansDetails = '';
bool _allowTypingIndicator = false; bool _allowTypingIndicator = false;
Timer? _typingGateTimer; Timer? _typingGateTimer;
// press state handled by shared ChatActionButton // press state handled by shared ChatActionButton
@@ -105,69 +105,53 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
if (raw.startsWith(searchBanner)) { if (raw.startsWith(searchBanner)) {
raw = raw.substring(searchBanner.length); raw = raw.substring(searchBanner.length);
} }
// Do not truncate content during streaming; segmented parser will skip // Do not truncate content during streaming; segmented parser skips
// incomplete details blocks and tiles will render once complete. // incomplete details blocks and tiles will render once complete.
final rc = ReasoningParser.parseReasoningContent(raw); final rSegs = ReasoningParser.segments(raw);
String base = rc?.mainContent ?? raw;
final tools = ToolCallsParser.parse(base); final out = <MessageSegment>[];
List<ToolCallsSegment>? segments = ToolCallsParser.segments(base); final textBuf = StringBuffer();
if (rSegs == null || rSegs.isEmpty) {
// Fallback: if parser failed but content has tool_calls details, synthesize segments final tSegs = ToolCallsParser.segments(raw);
if ((segments == null || segments.isEmpty) && base.contains('<details') && base.contains('type="tool_calls"')) { if (tSegs == null || tSegs.isEmpty) {
final fallbackSegs = <ToolCallsSegment>[]; out.add(MessageSegment.text(raw));
final detailsRegex = RegExp(r'<details[^>]*>([\s\S]*?)<\/details>', multiLine: true, dotAll: true); textBuf.write(raw);
final attrRegex = RegExp(r'(\w+)="([^"]*)"'); } else {
final matches = detailsRegex.allMatches(base).toList(); for (final s in tSegs) {
String textRemainder = base; if (s.isToolCall && s.entry != null) {
for (final m in matches) { out.add(MessageSegment.tool(s.entry!));
final full = m.group(0) ?? ''; } else if ((s.text ?? '').isNotEmpty) {
final openTag = RegExp(r'<details[^>]*>').firstMatch(full)?.group(0) ?? ''; out.add(MessageSegment.text(s.text!));
if (!openTag.contains('type="tool_calls"')) continue; textBuf.write(s.text);
final attrs = <String, String>{};
for (final am in attrRegex.allMatches(openTag)) {
attrs[am.group(1)!] = am.group(2) ?? '';
}
final id = attrs['id'] ?? '';
final name = attrs['name'] ?? 'tool';
final done = (attrs['done'] == 'true');
final args = attrs['arguments'];
final result = attrs['result'];
final files = attrs['files'];
dynamic decodeMaybe(String? s) {
if (s == null || s.isEmpty) return null;
try {
return json.decode(s);
} catch (_) {
return s;
} }
} }
final entry = ToolCallEntry(
id: id.isNotEmpty ? id : '${name}_${m.start}',
name: name,
done: done,
arguments: decodeMaybe(args),
result: decodeMaybe(result),
files: (decodeMaybe(files) is List) ? decodeMaybe(files) as List : null,
);
fallbackSegs.add(ToolCallsSegment.entry(entry));
textRemainder = textRemainder.replaceFirst(full, '');
} }
if (fallbackSegs.isNotEmpty) { } else {
final remainder = textRemainder.trim(); for (final rs in rSegs) {
if (remainder.isNotEmpty) { if (rs.isReasoning && rs.entry != null) {
fallbackSegs.add(ToolCallsSegment.text(remainder)); out.add(MessageSegment.reason(rs.entry!));
} else if ((rs.text ?? '').isNotEmpty) {
final t = rs.text!;
final tSegs = ToolCallsParser.segments(t);
if (tSegs == null || tSegs.isEmpty) {
out.add(MessageSegment.text(t));
textBuf.write(t);
} else {
for (final s in tSegs) {
if (s.isToolCall && s.entry != null) {
out.add(MessageSegment.tool(s.entry!));
} else if ((s.text ?? '').isNotEmpty) {
out.add(MessageSegment.text(s.text!));
textBuf.write(s.text);
}
}
}
} }
segments = fallbackSegs;
} }
} }
setState(() { setState(() {
_reasoningContent = rc; _segments = out.isEmpty ? [MessageSegment.text(raw)] : out;
_contentSansDetails = tools?.mainContent ?? base;
_toolSegments = segments ?? [ToolCallsSegment.text(_contentSansDetails)];
}); });
_updateTypingIndicatorGate(); _updateTypingIndicatorGate();
} }
@@ -345,19 +329,21 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
Widget _buildSegmentedContent() { Widget _buildSegmentedContent() {
final children = <Widget>[]; final children = <Widget>[];
bool firstToolSpacerAdded = false; bool firstToolSpacerAdded = false;
for (final seg in _toolSegments) { int idx = 0;
if (seg.isToolCall && seg.entry != null) { for (final seg in _segments) {
if (seg.isTool && seg.toolCall != null) {
// Add top spacing before the first tool block for clarity // Add top spacing before the first tool block for clarity
if (!firstToolSpacerAdded) { if (!firstToolSpacerAdded) {
children.add(const SizedBox(height: Spacing.sm)); children.add(const SizedBox(height: Spacing.sm));
firstToolSpacerAdded = true; firstToolSpacerAdded = true;
} }
children.add(_buildToolCallTile(seg.entry!)); children.add(_buildToolCallTile(seg.toolCall!));
} else if (seg.isReasoning && seg.reasoning != null) {
children.add(_buildReasoningTile(seg.reasoning!, idx));
} else if ((seg.text ?? '').trim().isNotEmpty) { } else if ((seg.text ?? '').trim().isNotEmpty) {
children.add( children.add(_buildEnhancedMarkdownContent(seg.text!));
_buildEnhancedMarkdownContent(seg.text!),
);
} }
idx++;
} }
if (children.isEmpty) return const SizedBox.shrink(); if (children.isEmpty) return const SizedBox.shrink();
@@ -379,6 +365,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
'', '',
); );
// Hide reasoning blocks as well in text check
cleaned = cleaned.replaceAll(
RegExp(
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
multiLine: true,
dotAll: true,
),
'',
);
// If last <details> is unclosed, drop tail to avoid rendering raw tag // If last <details> is unclosed, drop tail to avoid rendering raw tag
final lastOpen = cleaned.lastIndexOf('<details'); final lastOpen = cleaned.lastIndexOf('<details');
if (lastOpen >= 0) { if (lastOpen >= 0) {
@@ -390,8 +385,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return cleaned.trim().isNotEmpty; return cleaned.trim().isNotEmpty;
} }
for (final seg in _toolSegments) { for (final seg in _segments) {
if (seg.isToolCall && seg.entry != null) return true; if (seg.isTool && seg.toolCall != null) return true;
if (seg.isReasoning && seg.reasoning != null) return true;
final text = seg.text ?? ''; final text = seg.text ?? '';
if (_textRenderable(text)) return true; if (_textRenderable(text)) return true;
} }
@@ -454,108 +450,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
// Cached AI Name and Avatar to prevent flashing // Cached AI Name and Avatar to prevent flashing
_cachedAvatar ?? const SizedBox.shrink(), _cachedAvatar ?? const SizedBox.shrink(),
// Reasoning Section (if present) // Reasoning blocks are now rendered inline where they appear
if (_reasoningContent != null) ...[
InkWell(
onTap: () => setState(() => _showReasoning = !_showReasoning),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceContainer.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.thin,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_showReasoning
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 16,
color: context.conduitTheme.textSecondary,
),
const SizedBox(width: Spacing.xs),
Icon(
Icons.psychology_outlined,
size: 14,
color: context.conduitTheme.buttonPrimary,
),
const SizedBox(width: Spacing.xs),
Text(
() {
final l10n = AppLocalizations.of(context)!;
final rc = _reasoningContent!;
final hasSummary = rc.summary.isNotEmpty;
final isThinkingSummary = rc.summary.trim().toLowerCase() == 'thinking…' || rc.summary.trim().toLowerCase() == 'thinking...';
if (widget.isStreaming) {
// During streaming, prefer showing Thinking…
return hasSummary ? rc.summary : l10n.thinking;
}
// After streaming ends:
if (rc.duration > 0) {
return l10n.thoughtForDuration(rc.formattedDuration);
}
// If summary was just the placeholder 'Thinking…', replace with a neutral title
if (!hasSummary || isThinkingSummary) {
return l10n.thoughts;
}
return rc.summary;
}(),
style: TextStyle(
fontSize: AppTypography.bodySmall,
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
// Expandable reasoning content
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Container(
margin: const EdgeInsets.only(top: Spacing.sm),
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceContainer.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.thin,
),
),
child: SelectableText(
_reasoningContent!.cleanedReasoning,
style: TextStyle(
fontSize: AppTypography.bodySmall,
color: context.conduitTheme.textSecondary,
fontFamily: 'monospace',
height: 1.4,
),
),
),
crossFadeState: _showReasoning
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
const SizedBox(height: 0),
],
// Documentation-style content without heavy bubble; premium markdown // Documentation-style content without heavy bubble; premium markdown
SizedBox( SizedBox(
@@ -603,12 +498,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
); );
}, },
child: (!_hasRenderableSegments && child: (widget.isStreaming &&
_allowTypingIndicator && !_hasRenderableSegments &&
widget.isStreaming && _allowTypingIndicator)
(widget.message.content.trim().isEmpty ||
widget.message.content ==
'[TYPING_INDICATOR]'))
? KeyedSubtree( ? KeyedSubtree(
key: const ValueKey('typing'), key: const ValueKey('typing'),
child: _buildTypingIndicator(), child: _buildTypingIndicator(),
@@ -654,6 +546,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
'', '',
); );
// Also hide reasoning details blocks if any slipped into text
cleaned = cleaned.replaceAll(
RegExp(
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
multiLine: true,
dotAll: true,
),
'',
);
// Remove raw <think>...</think> or <reasoning>...</reasoning> tags in text
cleaned = cleaned
.replaceAll(RegExp(r'<think>[\s\S]*?<\/think>', multiLine: true, dotAll: true), '')
.replaceAll(RegExp(r'<reasoning>[\s\S]*?<\/reasoning>', multiLine: true, dotAll: true), '');
// If there's an unclosed <details>, drop the tail to avoid raw tags. // If there's an unclosed <details>, drop the tail to avoid raw tags.
final lastOpen = cleaned.lastIndexOf('<details'); final lastOpen = cleaned.lastIndexOf('<details');
@@ -907,4 +812,121 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
}) { }) {
return ChatActionButton(icon: icon, label: label, onTap: onTap); return ChatActionButton(icon: icon, label: label, onTap: onTap);
} }
// Reasoning tile rendered inline at the position it appears
Widget _buildReasoningTile(ReasoningEntry rc, int index) {
final isExpanded = _expandedReasoning.contains(index);
final theme = context.conduitTheme;
String headerText() {
final l10n = AppLocalizations.of(context)!;
final hasSummary = rc.summary.isNotEmpty;
final isThinkingSummary = rc.summary.trim().toLowerCase() == 'thinking…' ||
rc.summary.trim().toLowerCase() == 'thinking...';
if (widget.isStreaming) {
return hasSummary ? rc.summary : l10n.thinking;
}
if (rc.duration > 0) {
return l10n.thoughtForDuration(rc.formattedDuration);
}
if (!hasSummary || isThinkingSummary) {
return l10n.thoughts;
}
return rc.summary;
}
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.xs),
child: InkWell(
onTap: () {
setState(() {
if (isExpanded) {
_expandedReasoning.remove(index);
} else {
_expandedReasoning.add(index);
}
});
},
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: theme.surfaceContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: theme.dividerColor,
width: BorderWidth.thin,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isExpanded
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 16,
color: theme.textSecondary,
),
const SizedBox(width: Spacing.xs),
Icon(
Icons.psychology_outlined,
size: 14,
color: theme.buttonPrimary,
),
const SizedBox(width: Spacing.xs),
Flexible(
child: Text(
headerText(),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppTypography.bodySmall,
color: theme.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
],
),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Container(
margin: const EdgeInsets.only(top: Spacing.sm),
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: theme.surfaceContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: theme.dividerColor,
width: BorderWidth.thin,
),
),
child: SelectableText(
rc.cleanedReasoning,
style: TextStyle(
fontSize: AppTypography.bodySmall,
color: theme.textSecondary,
fontFamily: 'monospace',
height: 1.4,
),
),
),
crossFadeState:
isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
),
),
),
);
}
} }