Skip to content

OpenCode UI Patterns for ReCursor

Edit page

Mapping OpenCode’s terminal-native UI components to Flutter mobile widgets.


OpenCode (opencode-ai/opencode) is a terminal-native AI coding agent with a sophisticated UI for displaying tool use, diffs, and session state. ReCursor adapts these patterns for mobile Flutter.

Source Reference: C:/Repository/opencode/packages/ui/src/components/


OpenCode renders rich tool cards in the terminal. ReCursor adapts these as Flutter cards.

packages/ui/src/components/basic-tool.tsx
interface ToolCardProps {
tool: string;
params: Record<string, any>;
result?: ToolResult;
status: 'pending' | 'running' | 'completed' | 'error';
}
// Terminal output with ANSI colors and formatting
<ToolCard>
<ToolHeader icon={getToolIcon(tool)} name={tool} />
<ToolParams params={params} />
<ToolResult result={result} />
</ToolCard>
lib/features/chat/widgets/tool_card.dart
class ToolCard extends StatelessWidget {
final ToolUse tool;
final ToolResult? result;
final ToolStatus status;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
margin: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ToolHeader(tool: tool, status: status),
_ToolParams(params: tool.params),
if (result != null) _ToolResult(result: result),
],
),
);
}
}
StateOpenCodeReCursor
PendingSpinner + “Running…”CircularProgressIndicator + pulse animation
RunningLive output streamStreaming text with fade-in
CompletedCheckmark + resultIcons.check_circle + expandable result
ErrorRed X + error detailsIcons.error + error card

OpenCode shows syntax-highlighted diffs. ReCursor provides touch-friendly diff viewing.

packages/ui/src/components/diff-changes.tsx
interface DiffChangesProps {
files: DiffFile[];
viewMode: 'unified' | 'split';
}
// Terminal diff with ANSI colors
<DiffChanges>
{files.map(file => (
<DiffFile key={file.path}>
<DiffHeader path={file.path} stats={file.stats} />
<DiffHunks hunks={file.hunks} />
</DiffFile>
))}
</DiffChanges>
lib/features/diff/widgets/diff_viewer.dart
class DiffViewer extends StatelessWidget {
final List<DiffFile> files;
final DiffViewMode viewMode;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: files.length,
itemBuilder: (context, index) {
return DiffFileCard(
file: files[index],
viewMode: viewMode,
);
},
);
}
}
class DiffFileCard extends StatelessWidget {
final DiffFile file;
@override
Widget build(BuildContext context) {
return ExpansionTile(
leading: _FileStatusIcon(status: file.status),
title: Text(file.path),
subtitle: Text('+${file.additions} -${file.deletions}'),
children: [
DiffHunksView(hunks: file.hunks),
],
);
}
}
// Syntax-highlighted diff lines
class DiffLine extends StatelessWidget {
final DiffLineType type; // added, removed, context
final String content;
final int? lineNumber;
@override
Widget build(BuildContext context) {
return Container(
color: _backgroundColorForType(type),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
children: [
if (lineNumber != null)
Text('$lineNumber', style: TextStyle(color: Colors.grey)),
SizedBox(width: 8),
_DiffMarker(type: type),
Expanded(
child: SyntaxHighlightedText(
code: content,
language: file.extension,
),
),
],
),
);
}
}

OpenCode shows a timeline of session events. ReCursor adapts this as a scrollable timeline.

packages/ui/src/components/session-turn.tsx
interface SessionTurnProps {
turns: Turn[];
currentTurn: number;
}
// Terminal timeline with turn markers
<SessionTurn>
{turns.map((turn, index) => (
<TurnMarker
key={turn.id}
index={index}
active={index === currentTurn}
type={turn.type}
/>
))}
</SessionTurn>
lib/features/session/widgets/session_timeline.dart
class SessionTimeline extends StatelessWidget {
final List<SessionEvent> events;
final String? currentEventId;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: events.length,
itemBuilder: (context, index) {
return TimelineTile(
event: events[index],
isActive: events[index].id == currentEventId,
isFirst: index == 0,
isLast: index == events.length - 1,
);
},
);
}
}
class TimelineTile extends StatelessWidget {
final SessionEvent event;
final bool isActive;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline connector
Column(
children: [
_TimelineDot(
type: event.type,
isActive: isActive,
),
if (!isLast) _TimelineConnector(),
],
),
SizedBox(width: 12),
// Event content
Expanded(
child: _EventCard(event: event),
),
],
);
}
}

OpenCode renders message content as typed parts. ReCursor uses similar part-based rendering.

packages/ui/src/components/message-part.tsx
interface MessagePartProps {
part: MessagePart;
}
type MessagePart =
| { type: 'text'; content: string }
| { type: 'tool_use'; tool: string; params: any }
| { type: 'tool_result'; result: ToolResult }
| { type: 'thinking'; content: string };
// Render based on part type
function MessagePart({ part }: MessagePartProps) {
switch (part.type) {
case 'text':
return <MarkdownText content={part.content} />;
case 'tool_use':
return <ToolCard tool={part.tool} params={part.params} />;
case 'tool_result':
return <ToolResult result={part.result} />;
case 'thinking':
return <ThinkingBlock content={part.content} />;
}
}
lib/features/chat/widgets/message_part.dart
class MessagePart extends StatelessWidget {
final MessagePartEntity part;
@override
Widget build(BuildContext context) {
return part.map(
text: (p) => MarkdownText(content: p.content),
toolUse: (p) => ToolCard(
tool: p.tool,
params: p.params,
status: ToolStatus.pending,
),
toolResult: (p) => ToolResultCard(result: p.result),
thinking: (p) => ThinkingBlock(content: p.content),
);
}
}
// Freezed union for type-safe message parts
@freezed
class MessagePartEntity with _$MessagePartEntity {
const factory MessagePartEntity.text({
required String content,
}) = TextPart;
const factory MessagePartEntity.toolUse({
required String tool,
required Map<String, dynamic> params,
}) = ToolUsePart;
const factory MessagePartEntity.toolResult({
required ToolResult result,
}) = ToolResultPart;
const factory MessagePartEntity.thinking({
required String content,
}) = ThinkingPart;
}

OpenCode ComponentReCursor WidgetFile
BasicToolToolCardlib/features/chat/widgets/tool_card.dart
DiffChangesDiffViewerlib/features/diff/widgets/diff_viewer.dart
SessionTurnSessionTimelinelib/features/session/widgets/session_timeline.dart
MessagePartMessagePartlib/features/chat/widgets/message_part.dart
ChatMessageMessageBubblelib/features/chat/widgets/message_bubble.dart
lib/shared/widgets/
// Tool icon based on tool name
class ToolIcon extends StatelessWidget {
final String tool;
IconData get icon {
return switch (tool) {
'edit_file' => Icons.edit,
'read_file' => Icons.file_open,
'run_command' => Icons.terminal,
'glob' => Icons.folder,
'grep' => Icons.search,
_ => Icons.build,
};
}
}
// Status indicator for tool execution
class ToolStatusIndicator extends StatelessWidget {
final ToolStatus status;
@override
Widget build(BuildContext context) {
return switch (status) {
ToolStatus.pending => SpinKitPulse(color: Colors.blue),
ToolStatus.running => SpinKitWave(color: Colors.orange),
ToolStatus.completed => Icon(Icons.check_circle, color: Colors.green),
ToolStatus.error => Icon(Icons.error, color: Colors.red),
};
}
}
// Expandable code block with syntax highlighting
class CodeBlock extends StatelessWidget {
final String code;
final String? language;
@override
Widget build(BuildContext context) {
return ExpandablePanel(
header: Text(language ?? 'Code'),
collapsed: _TruncatedCode(code: code),
expanded: SyntaxHighlightedCode(
code: code,
language: language,
),
);
}
}

OpenCode uses a terminal-inspired color scheme:

ElementOpenCode (Terminal)ReCursor (Flutter)
Background#1e1e1e (dark)Color(0xFF1E1E1E)
Text#d4d4d4Color(0xFFD4D4D4)
Added lines#4ec9b0 (green)Colors.green[400]
Removed lines#f44747 (red)Colors.red[400]
Tool header#569cd6 (blue)Colors.blue[400]
Accent#ce9178 (orange)Colors.orange[300]
lib/core/theme/app_theme.dart
class AppTheme {
static ThemeData get darkTheme {
return ThemeData.dark().copyWith(
colorScheme: ColorScheme.dark(
primary: Color(0xFF569CD6),
secondary: Color(0xFF4EC9B0),
surface: Color(0xFF1E1E1E),
background: Color(0xFF121212),
error: Color(0xFFF44747),
),
cardTheme: CardTheme(
color: Color(0xFF252526),
elevation: 2,
),
textTheme: TextTheme(
bodyMedium: TextStyle(
color: Color(0xFFD4D4D4),
fontFamily: 'JetBrainsMono',
),
),
);
}
}

OpenCode (Terminal)ReCursor (Mobile)
Fixed-width fontDynamic font sizing
Horizontal scrollingHorizontal swipe gestures
Keyboard shortcutsTouch gestures + FABs
Split panesTab navigation
Mouse hoverLong-press menus
lib/features/chat/screens/chat_screen.dart
class ChatScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
// Tablet: Split view
return Row(
children: [
Expanded(flex: 2, child: ChatPanel()),
Expanded(flex: 3, child: DetailPanel()),
],
);
}
// Phone: Single panel
return ChatPanel();
},
);
}
}

// Smooth expansion when tool completes
class ToolCard extends StatefulWidget {
@override
_ToolCardState createState() => _ToolCardState();
}
class _ToolCardState extends State<ToolCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _expandAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 300),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
void didUpdateWidget(ToolCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.status == ToolStatus.completed &&
oldWidget.status != ToolStatus.completed) {
_controller.forward();
}
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _expandAnimation,
child: Card(/* ... */),
);
}
}


Last updated: 2026-03-17