Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
@@ -0,0 +1,39 @@
import React from 'react';
import { RiAlignJustify, RiLayoutColumnLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export type DiffViewMode = 'side-by-side' | 'unified';
interface DiffViewToggleProps {
mode: DiffViewMode;
onModeChange: (mode: DiffViewMode) => void;
className?: string;
}
export const DiffViewToggle: React.FC<DiffViewToggleProps> = ({ mode, onModeChange, className }) => {
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onModeChange(mode === 'side-by-side' ? 'unified' : 'side-by-side');
},
[mode, onModeChange]
);
return (
<Button
size="sm"
variant="ghost"
className={cn('h-5 w-5 p-0 opacity-60 hover:opacity-100', className)}
onClick={handleClick}
title={mode === 'side-by-side' ? 'Switch to unified view' : 'Switch to side-by-side view'}
>
{mode === 'side-by-side' ? (
<RiAlignJustify className="h-3 w-3" />
) : (
<RiLayoutColumnLine className="h-3 w-3" />
)}
</Button>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface FadeInOnRevealProps {
children: React.ReactNode;
className?: string;
}
const FADE_ANIMATION_ENABLED = true;
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className }) => {
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
if (!FADE_ANIMATION_ENABLED) {
return;
}
let frame: number | null = null;
const enable = () => setVisible(true);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
frame = window.requestAnimationFrame(enable);
} else {
enable();
}
return () => {
if (
frame !== null &&
typeof window !== 'undefined' &&
typeof window.cancelAnimationFrame === 'function'
) {
window.cancelAnimationFrame(frame);
}
};
}, []);
if (!FADE_ANIMATION_ENABLED) {
return <>{children}</>;
}
return (
<div
className={cn(
'w-full transition-all duration-300 ease-out',
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2',
className
)}
>
{children}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import React from 'react';
import { RiBrainAi3Line, RiUser3Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors';
import { FadeInOnReveal } from './FadeInOnReveal';
import { useProviderLogo } from '@/hooks/useProviderLogo';
interface MessageHeaderProps {
isUser: boolean;
providerID: string | null;
agentName: string | undefined;
modelName: string | undefined;
isDarkTheme: boolean;
}
const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, isDarkTheme }) => {
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
return (
<FadeInOnReveal>
<div className={cn('pl-3', 'mb-2')}>
<div className={cn('flex items-center justify-between gap-2')}>
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{isUser ? (
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<RiUser3Line className="h-4 w-4 text-primary" />
</div>
) : (
<div className="flex items-center justify-center">
{hasLogo && logoSrc ? (
<img
src={logoSrc}
alt={`${providerID} logo`}
className="h-4 w-4"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleLogoError}
/>
) : (
<RiBrainAi3Line
className="h-4 w-4"
style={{ color: `var(${getAgentColor(agentName).var})` }}
/>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<h3
className={cn(
'font-bold typography-ui-header tracking-tight leading-none',
isUser ? 'text-primary' : 'text-foreground'
)}
>
{isUser ? 'You' : (modelName || 'Assistant')}
</h3>
{!isUser && agentName && (
<div
className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded',
'agent-badge typography-meta',
getAgentColor(agentName).class
)}
>
<span className="font-medium">{agentName}</span>
</div>
)}
</div>
</div>
</div>
</div>
</FadeInOnReveal>
);
};
export default React.memo(MessageHeader);
@@ -0,0 +1,563 @@
import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { RiBrainAi3Line, RiFileImageLine, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { Streamdown } from 'streamdown';
import { cn } from '@/lib/utils';
import { toolDisplayStyles } from '@/lib/typography';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import {
renderTodoOutput,
renderListOutput,
renderGrepOutput,
renderGlobOutput,
renderWebSearchOutput,
formatInputForDisplay,
parseDiffToUnified,
type UnifiedDiffHunk,
type SideBySideDiffHunk,
type SideBySideDiffLine,
} from './toolRenderers';
import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
onOpenChange: (open: boolean) => void;
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
}
const getToolIcon = (toolName: string) => {
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
const tool = toolName.toLowerCase();
if (tool === 'reasoning') {
return <RiBrainAi3Line className={iconClass} />;
}
if (tool === 'image-preview') {
return <RiFileImageLine className={iconClass} />;
}
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
return <RiPencilAiLine className={iconClass} />;
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
return <RiFilePdfLine className={iconClass} />;
}
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
return <RiFilePdfLine className={iconClass} />;
}
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
return <RiTerminalBoxLine className={iconClass} />;
}
if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
return <RiFolder6Line className={iconClass} />;
}
if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
return <RiSearchLine className={iconClass} />;
}
if (tool === 'glob') {
return <RiFileSearchLine className={iconClass} />;
}
if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
return <RiGlobalLine className={iconClass} />;
}
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web' || tool === 'google' || tool === 'bing' || tool === 'duckduckgo') {
return <RiSearchLine className={iconClass} />;
}
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
return <RiToolsLine className={iconClass} />;
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
return (
<Dialog open={popup.open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'overflow-hidden flex flex-col min-h-0 pt-3 pb-4 px-4 gap-1',
'[&>button]:top-1.5',
isMobile ? 'w-[95vw] max-w-[95vw]' : 'max-w-5xl',
isMobile ? '[&>button]:right-1' : '[&>button]:top-2.5 [&>button]:right-4'
)}
style={{ maxHeight: '90vh' }}
>
<div className="flex-shrink-0 pb-1">
<div className="flex items-start gap-2 text-foreground typography-ui-header font-semibold">
{popup.metadata?.tool ? getToolIcon(popup.metadata.tool as string) : (
<RiToolsLine className="h-3.5 w-3.5 text-foreground flex-shrink-0" />
)}
<span className="break-words flex-1 leading-tight">{popup.title}</span>
{popup.isDiff && (
<DiffViewToggle
mode={diffViewMode}
onModeChange={setDiffViewMode}
className="mr-8 flex-shrink-0"
/>
)}
</div>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border/30 bg-muted/10 overflow-hidden">
<div className="h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
Object.keys(popup.metadata.input).length > 0 &&
popup.metadata?.tool !== 'todowrite' &&
popup.metadata?.tool !== 'todoread' ? (() => {
const meta = popup.metadata!;
const input = meta.input as Record<string, unknown>;
const getInputValue = (key: string): string | null => {
const val = input[key];
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : null);
};
return (
<div className="border-b border-border/20 p-4 -mx-3">
<div className="typography-markdown font-medium text-muted-foreground mb-2 px-3">
{meta.tool === 'bash'
? 'Command:'
: meta.tool === 'task'
? 'Task Details:'
: 'Input:'}
</div>
{meta.tool === 'bash' && getInputValue('command') ? (
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter
style={syntaxTheme}
language="bash"
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
wrapLongLines
>
{getInputValue('command')!}
</SyntaxHighlighter>
</div>
) : meta.tool === 'task' && getInputValue('prompt') ? (
<pre
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
style={toolDisplayStyles.getPopupStyles()}
>
{getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
{getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
{`Instructions:\n${getInputValue('prompt')}`}
</pre>
) : meta.tool === 'write' && getInputValue('content') ? (
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || 'text'}
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
wrapLongLines
>
{getInputValue('content')!}
</SyntaxHighlighter>
</div>
) : (
<pre
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
style={toolDisplayStyles.getPopupStyles()}
>
{formatInputForDisplay(input, meta.tool as string)}
</pre>
)}
</div>
);
})() : null}
{popup.isDiff ? (
diffViewMode === 'unified' ? (
<div className="typography-markdown">
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<div>
{hunk.lines.map((line, lineIdx) => (
<div
key={lineIdx}
className={cn(
'typography-markdown font-mono px-3 py-0.5 flex',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
)}
style={{
lineHeight: '1.1',
...(line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)' }
: {}),
}}
>
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
{(() => {
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
: null;
const fileStr = typeof inputFile === 'string' ? inputFile : '';
const hunkFile = (typeof hunk === 'object' && hunk !== null && 'file' in hunk) ? (hunk as UnifiedDiffHunk).file : null;
const hunkFileStr = typeof hunkFile === 'string' ? hunkFile : '';
const finalFile = fileStr || hunkFileStr || '';
return (
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(finalFile) || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent !important',
},
}}
>
{line.content}
</SyntaxHighlighter>
);
})()}
</div>
</div>
))}
</div>
</div>
))}
</div>
) : popup.diffHunks ? (
<div className="typography-markdown">
{popup.diffHunks.map((hunk, hunkIdx) => (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<div>
{(hunk as unknown as SideBySideDiffHunk).lines.map((line: SideBySideDiffLine, lineIdx: number) => (
<div key={lineIdx} className="grid grid-cols-2 divide-x divide-border/20">
<div
className={cn(
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
line.leftLine.type === 'context' && 'bg-transparent',
line.leftLine.type === 'empty' && 'bg-transparent'
)}
style={{
lineHeight: '1.1',
...(line.leftLine.type === 'removed' ? { backgroundColor: 'var(--tools-edit-removed-bg)' } : {}),
}}
>
<div className="flex">
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
{line.leftLine.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
{line.leftLine.content && (
<SyntaxHighlighter
style={syntaxTheme}
language={(() => {
const input = popup.metadata?.input;
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
const filePath = inputObj.file_path || inputObj.filePath;
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || 'text';
})()}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent !important',
},
}}
>
{line.leftLine.content}
</SyntaxHighlighter>
)}
</div>
</div>
</div>
<div
className={cn(
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
line.rightLine.type === 'context' && 'bg-transparent',
line.rightLine.type === 'empty' && 'bg-transparent'
)}
style={{
lineHeight: '1.1',
...(line.rightLine.type === 'added' ? { backgroundColor: 'var(--tools-edit-added-bg)' } : {}),
}}
>
<div className="flex">
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
{line.rightLine.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
{line.rightLine.content && (
<SyntaxHighlighter
style={syntaxTheme}
language={(() => {
const input = popup.metadata?.input;
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
const filePath = inputObj.file_path || inputObj.filePath;
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || 'text';
})()}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent !important',
},
}}
>
{line.rightLine.content}
</SyntaxHighlighter>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
) : null
) : popup.image ? (
<div className="p-4">
<div className="flex flex-col items-center gap-3">
<div className="max-h-[70vh] overflow-hidden rounded-2xl border border-border/40 bg-muted/10">
<img
src={popup.image.url}
alt={popup.image.filename || popup.title || 'Image preview'}
className="block h-full max-h-[70vh] w-auto max-w-full object-contain"
loading="lazy"
/>
</div>
{popup.image.filename && (
<span className="typography-meta text-muted-foreground text-center">
{popup.image.filename}
</span>
)}
</div>
</div>
) : popup.content ? (
<div className="p-4">
{(() => {
const tool = popup.metadata?.tool;
if (tool === 'todowrite' || tool === 'todoread') {
return (
renderTodoOutput(popup.content) || (
<SyntaxHighlighter
style={syntaxTheme}
language="json"
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent !important' } }}
>
{popup.content}
</SyntaxHighlighter>
)
);
}
if (tool === 'list') {
return (
renderListOutput(popup.content) || (
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
{popup.content}
</pre>
)
);
}
if (tool === 'grep') {
return (
renderGrepOutput(popup.content, isMobile) || (
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
{popup.content}
</pre>
)
);
}
if (tool === 'glob') {
return (
renderGlobOutput(popup.content, isMobile) || (
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
{popup.content}
</pre>
)
);
}
if (tool === 'task' || tool === 'reasoning') {
return (
<div
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
style={{ fontSize: 'var(--text-meta)' }}
>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{popup.content}
</Streamdown>
</div>
);
}
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
return (
renderWebSearchOutput(popup.content, syntaxTheme) || (
<SyntaxHighlighter
style={syntaxTheme}
language="text"
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent !important' } }}
>
{popup.content}
</SyntaxHighlighter>
)
);
}
if (tool === 'read') {
const lines = popup.content.split('\n');
const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined;
const isInfoMessage = (line: string) => line.trim().startsWith('(');
return (
<div>
{lines.map((line: string, idx: number) => {
const isInfo = isInfoMessage(line);
const lineNumber = offset + idx + 1;
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
return (
<div key={idx} className={`typography-markdown font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-4 self-start select-none">
{shouldShowLineNumber ? lineNumber : ''}
</span>
<div className="flex-1 min-w-0">
{isInfo ? (
<div className="whitespace-pre-wrap break-words">{line}</div>
) : (
<SyntaxHighlighter
style={syntaxTheme}
language={popup.language || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent !important',
},
}}
>
{line}
</SyntaxHighlighter>
)}
</div>
</div>
);
})}
</div>
);
}
return (
<SyntaxHighlighter
style={syntaxTheme}
language={popup.language || 'text'}
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent !important' } }}
>
{popup.content}
</SyntaxHighlighter>
);
})()}
</div>
) : (
<div className="p-8 text-muted-foreground typography-ui-header">
<div className="mb-2">Command completed successfully</div>
<div className="typography-markdown">No output was produced</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default ToolOutputDialog;
@@ -0,0 +1,34 @@
import type { Message } from '@opencode-ai/sdk';
export interface MessageRoleInfo {
role: string;
isUser: boolean;
}
export const deriveMessageRole = (
messageInfo: Message | (Message & { clientRole?: string; userMessageMarker?: boolean })
): MessageRoleInfo => {
const info = messageInfo as Message & { clientRole?: string; userMessageMarker?: boolean; origin?: string; source?: string };
const clientRole = info?.clientRole;
const serverRole = info?.role;
const userMarker = info?.userMessageMarker === true;
const isUser =
userMarker ||
clientRole === 'user' ||
serverRole === 'user' ||
info?.origin === 'user' ||
info?.source === 'user';
if (isUser) {
return {
role: 'user',
isUser: true,
};
}
return {
role: clientRole || serverRole || 'assistant',
isUser: false,
};
};
@@ -0,0 +1,54 @@
import type { Part } from '@opencode-ai/sdk';
type PartWithText = Part & { text?: string; content?: string; value?: string };
export const extractTextContent = (part: Part): string => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
if (typeof rawText === 'string') {
return rawText;
}
return partWithText.content || partWithText.value || '';
};
export const isEmptyTextPart = (part: Part): boolean => {
if (part.type !== 'text') {
return false;
}
const text = extractTextContent(part);
return !text || text.trim().length === 0;
};
type PartWithSynthetic = Part & { synthetic?: boolean };
interface VisibleFilterOptions {
includeReasoning?: boolean;
}
export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions = {}): Part[] => {
const { includeReasoning = true } = options;
return parts.filter((part) => {
const partWithSynthetic = part as PartWithSynthetic;
const isSynthetic = Boolean(partWithSynthetic.synthetic);
if (isSynthetic) {
return false;
}
if (!includeReasoning && part.type === 'reasoning') {
return false;
}
const isPatchPart = part.type === 'patch';
return !isPatchPart;
});
};
type PartWithTime = Part & { time?: { start?: number; end?: number } };
export const isFinalizedTextPart = (part: Part): boolean => {
if (part.type !== 'text') {
return false;
}
const time = (part as PartWithTime).time;
return Boolean(time && typeof time.end !== 'undefined');
};
@@ -0,0 +1,81 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ReasoningTimelineBlock, formatReasoningText } from './ReasoningPart';
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
interface AssistantTextPartProps {
part: Part;
messageId: string;
streamPhase: StreamPhase;
allowAnimation: boolean;
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
renderAsReasoning?: boolean;
}
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
part,
messageId,
streamPhase,
allowAnimation,
onContentChange,
renderAsReasoning = false,
}) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
const baseTextContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
const textContent = React.useMemo(() => {
if (renderAsReasoning) {
return formatReasoningText(baseTextContent);
}
return baseTextContent;
}, [baseTextContent, renderAsReasoning]);
const isStreamingPhase = streamPhase === 'streaming';
const isCooldownPhase = streamPhase === 'cooldown';
const wasStreamingRef = React.useRef(isStreamingPhase);
if (isStreamingPhase || isCooldownPhase) {
wasStreamingRef.current = true;
return null;
}
const time = partWithText.time;
const isFinalized = time && typeof time.end !== 'undefined';
if (!isFinalized && (!textContent || textContent.trim().length === 0)) {
return null;
}
if (!textContent || textContent.trim().length === 0) {
return null;
}
if (renderAsReasoning) {
return (
<ReasoningTimelineBlock
key={part.id || `${messageId}-text`}
text={textContent}
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning-text`}
/>
);
}
return (
<div className="group/assistant-text relative break-words" key={part.id || `${messageId}-text`}>
<MarkdownRenderer
content={textContent}
part={part}
messageId={messageId}
isAnimated={allowAnimation}
isStreaming={false}
/>
</div>
);
};
export default AssistantTextPart;
@@ -0,0 +1,51 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ReasoningTimelineBlock } from './ReasoningPart';
type PartWithText = Part & { text?: string; content?: string };
const cleanJustificationText = (text: string): string => {
if (typeof text !== 'string' || text.trim().length === 0) {
return '';
}
return text
.split('\n')
.map((line: string) => line.replace(/^>\s?/, '').trimEnd())
.filter((line: string) => line.trim().length > 0)
.join('\n')
.trim();
};
interface JustificationBlockProps {
part: Part;
messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
}
const JustificationBlock: React.FC<JustificationBlockProps> = ({
part,
messageId,
onContentChange,
}) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null;
if (!timeInfo?.end) {
return null;
}
return (
<ReasoningTimelineBlock
text={textContent}
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`}
/>
);
};
export default React.memo(JustificationBlock);
@@ -0,0 +1,31 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface MigratingPartProps {
isMigrating: boolean;
children: React.ReactNode;
className?: string;
}
const MigratingPart: React.FC<MigratingPartProps> = ({
isMigrating,
children,
className,
}) => {
return (
<div
className={cn(
'w-full overflow-hidden',
isMigrating && 'pointer-events-none',
className
)}
style={isMigrating ? { animation: 'oc-migrate-up 220ms ease-out forwards' } : undefined}
>
{children}
</div>
);
};
export default React.memo(MigratingPart);
@@ -0,0 +1,259 @@
import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { TurnActivityPart } from '../../hooks/useTurnGrouping';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart';
import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock';
import { FadeInOnReveal } from '../FadeInOnReveal';
interface DiffStats {
additions: number;
deletions: number;
files: number;
}
interface ProgressiveGroupProps {
parts: TurnActivityPart[];
isExpanded: boolean;
onToggle: () => void;
syntaxTheme: Record<string, React.CSSProperties>;
isMobile: boolean;
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
isWorking: boolean;
previewedPartIds: Set<string>;
diffStats?: DiffStats;
}
const getGroupSummary = (parts: TurnActivityPart[]): string => {
const counts = {
tools: parts.filter((p) => p.kind === 'tool').length,
reasoning: parts.filter((p) => p.kind === 'reasoning').length,
justifications: parts.filter((p) => p.kind === 'justification').length,
};
const segments: string[] = [];
if (counts.tools > 0) {
segments.push(`${counts.tools} tool${counts.tools > 1 ? 's' : ''}`);
}
if (counts.reasoning > 0) {
segments.push(`${counts.reasoning} reasoning`);
}
if (counts.justifications > 0) {
segments.push(`${counts.justifications} justification${counts.justifications > 1 ? 's' : ''}`);
}
return segments.join(', ');
};
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
return [...parts].sort((a, b) => {
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
const bTime = typeof b.endedAt === 'number' ? b.endedAt : undefined;
if (aTime === undefined && bTime === undefined) return 0;
if (aTime === undefined) return 1;
if (bTime === undefined) return -1;
return aTime - bTime;
});
};
const getToolConnections = (
parts: TurnActivityPart[]
): Record<string, { hasPrev: boolean; hasNext: boolean }> => {
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
const toolParts = parts.filter((p) => p.kind === 'tool');
toolParts.forEach((activity, index) => {
const partId = activity.part.id;
if (partId) {
connections[partId] = {
hasPrev: index > 0,
hasNext: index < toolParts.length - 1,
};
}
});
return connections;
};
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
parts,
isExpanded,
onToggle,
syntaxTheme,
isMobile,
expandedTools,
onToggleTool,
onContentChange,
isWorking,
previewedPartIds,
diffStats,
}) => {
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
React.useEffect(() => {
if (previousExpandedRef.current === isExpanded) return;
previousExpandedRef.current = isExpanded;
onContentChange?.('structural');
}, [isExpanded, onContentChange]);
const displayParts = React.useMemo(() => {
if (!isWorking) {
return sortPartsByTime(parts);
}
if (isExpanded) {
return sortPartsByTime(parts);
}
return sortPartsByTime(
parts.filter((activity) => {
const partId = activity.part.id;
return partId && previewedPartIds.has(activity.id);
})
);
}, [parts, isWorking, isExpanded, previewedPartIds]);
const summary = getGroupSummary(displayParts);
const toolConnections = getToolConnections(displayParts);
if (displayParts.length === 0) {
return null;
}
return (
<FadeInOnReveal>
<div className="my-1">
{}
<div
className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl cursor-pointer'
)}
onClick={onToggle}
>
<div className="flex items-center gap-2 flex-shrink-0">
{}
<div className="relative h-3.5 w-3.5 flex-shrink-0">
{}
<div
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
)}
>
<RiStackLine className="h-3.5 w-3.5" />
</div>
{}
<div
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && isMobile && 'opacity-0',
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? (
<RiArrowDownSLine className="h-3.5 w-3.5" />
) : (
<RiArrowRightSLine className="h-3.5 w-3.5" />
)}
</div>
</div>
<span className="typography-meta font-medium">Activity</span>
</div>
{(summary || diffStats) && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
{summary && (
<span className="truncate block">{summary}</span>
)}
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
<span className="flex-shrink-0 leading-none">
<span className="text-[color:var(--status-success)]">
+{Math.max(0, diffStats.additions)}
</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-destructive">
-{Math.max(0, diffStats.deletions)}
</span>
</span>
)}
</div>
)}
</div>
{}
{isExpanded && (
<div
className={cn(
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
'before:top-[-0.25rem] before:bottom-0'
)}
>
{displayParts.map((activity, index) => {
const partId = activity.part.id || `group-part-${index}`;
const connection = toolConnections[partId];
switch (activity.kind) {
case 'tool':
return (
<FadeInOnReveal key={partId}>
<ToolPart
part={activity.part as ToolPartType}
isExpanded={expandedTools.has(partId)}
onToggle={onToggleTool}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
hasPrevTool={connection?.hasPrev ?? false}
hasNextTool={connection?.hasNext ?? false}
/>
</FadeInOnReveal>
);
case 'reasoning':
return (
<FadeInOnReveal key={partId}>
<ReasoningPart
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
/>
</FadeInOnReveal>
);
case 'justification':
return (
<FadeInOnReveal key={partId}>
<JustificationBlock
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
/>
</FadeInOnReveal>
);
default:
return null;
}
})}
</div>
)}
</div>
</FadeInOnReveal>
);
};
export default React.memo(ProgressiveGroup);
@@ -0,0 +1,181 @@
import React from 'react';
import type { ComponentType } from 'react';
import type { Part } from '@opencode-ai/sdk';
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
type PartWithText = Part & { text?: string; content?: string };
export type ReasoningVariant = 'thinking' | 'justification';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
const variantConfig: Record<
ReasoningVariant,
{ label: string; Icon: IconComponent }
> = {
thinking: { label: 'Thinking', Icon: RiBrainAi3Line },
justification: { label: 'Justification', Icon: RiChatAi3Line },
};
const cleanReasoningText = (text: string): string => {
if (typeof text !== 'string' || text.trim().length === 0) {
return '';
}
return text
.split('\n')
.map((line: string) => line.replace(/^>\s?/, '').trimEnd())
.filter((line: string) => line.trim().length > 0)
.join('\n')
.trim();
};
const getReasoningSummary = (text: string): string => {
if (!text) {
return '';
}
const trimmed = text.trim();
const newlineIndex = trimmed.indexOf('\n');
const periodIndex = trimmed.indexOf('.');
const cutoffCandidates = [
newlineIndex >= 0 ? newlineIndex : Infinity,
periodIndex >= 0 ? periodIndex : Infinity,
];
const cutoff = Math.min(...cutoffCandidates);
if (!Number.isFinite(cutoff)) {
return trimmed;
}
return trimmed.substring(0, cutoff).trim();
};
type ReasoningTimelineBlockProps = {
text: string;
variant: ReasoningVariant;
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string;
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
text,
variant,
onContentChange,
blockId,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const { label, Icon } = variantConfig[variant];
React.useEffect(() => {
if (text.trim().length === 0) {
return;
}
onContentChange?.('structural');
}, [onContentChange, isExpanded, text]);
if (!text || text.trim().length === 0) {
return null;
}
return (
<div className="my-1" data-reasoning-block-id={blockId}>
<div
className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
)}
onClick={() => setIsExpanded((prev) => !prev)}
>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="relative h-3.5 w-3.5 flex-shrink-0">
<div
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
)}
>
<Icon className="h-3.5 w-3.5" />
</div>
<div
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
</div>
</div>
<span className="typography-meta font-medium">{label}</span>
</div>
{summary && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
<span className="truncate block">{summary}</span>
</div>
)}
</div>
{isExpanded && (
<div
className={cn(
'relative pr-2 pb-2 pt-2 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
'before:top-[-0.25rem] before:bottom-0'
)}
>
<ScrollableOverlay
as="blockquote"
outerClassName="max-h-80"
className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70 p-0"
>
{text}
</ScrollableOverlay>
</div>
)}
</div>
);
};
type ReasoningPartProps = {
part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string;
};
const ReasoningPart: React.FC<ReasoningPartProps> = ({
part,
onContentChange,
messageId,
}) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null;
if (!timeInfo?.end) {
return null;
}
return (
<ReasoningTimelineBlock
text={textContent}
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
/>
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
export default ReasoningPart;
@@ -0,0 +1,765 @@
import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { Streamdown } from 'streamdown';
import { cn } from '@/lib/utils';
import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk';
import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import {
renderListOutput,
renderGrepOutput,
renderGlobOutput,
renderTodoOutput,
renderWebSearchOutput,
parseDiffToUnified,
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
hasLspDiagnostics,
} from '../toolRenderers';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
interface ToolPartProps {
part: ToolPartType;
isExpanded: boolean;
onToggle: (toolId: string) => void;
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
hasPrevTool?: boolean;
hasNextTool?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components
export const getToolIcon = (toolName: string) => {
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
const tool = toolName.toLowerCase();
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
return <RiPencilLine className={iconClass} />;
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
return <RiFileEditLine className={iconClass} />;
}
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
return <RiFileTextLine className={iconClass} />;
}
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
return <RiTerminalBoxLine className={iconClass} />;
}
if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
return <RiFolder6Line className={iconClass} />;
}
if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
return <RiMenuSearchLine className={iconClass} />;
}
if (tool === 'glob') {
return <RiFileSearchLine className={iconClass} />;
}
if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
return <RiGlobalLine className={iconClass} />;
}
if (
tool === 'web-search' ||
tool === 'websearch' ||
tool === 'search_web' ||
tool === 'codesearch' ||
tool === 'google' ||
tool === 'bing' ||
tool === 'duckduckgo' ||
tool === 'perplexity'
) {
return <RiGlobalLine className={iconClass} />;
}
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
return <RiToolsLine className={iconClass} />;
};
const formatDuration = (start: number, end?: number) => {
const duration = end ? end - start : Date.now() - start;
const seconds = duration / 1000;
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
return `${displaySeconds.toFixed(1)}s`;
};
const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
const lines = metadata.diff.split('\n');
let added = 0;
let removed = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) added++;
if (line.startsWith('-') && !line.startsWith('---')) removed++;
}
if (added === 0 && removed === 0) return null;
return { added, removed };
};
const getRelativePath = (absolutePath: string, currentDirectory: string, isMobile: boolean): string => {
if (isMobile) {
return absolutePath.split('/').pop() || absolutePath;
}
if (absolutePath.startsWith(currentDirectory)) {
const relativePath = absolutePath.substring(currentDirectory.length);
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
}
return absolutePath;
};
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: boolean, currentDirectory: string): string => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory, isMobile);
}
}
if ((part.tool === 'read' || part.tool === 'write') && input) {
const filePath = input?.filePath || input?.file_path || input?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory, isMobile);
}
}
if (part.tool === 'bash' && input?.command && typeof input.command === 'string') {
const firstLine = input.command.split('\n')[0];
return isMobile ? firstLine.substring(0, 50) : firstLine.substring(0, 100);
}
if (part.tool === 'task' && input?.description && typeof input.description === 'string') {
return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80);
}
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
return typeof desc === 'string' ? desc : '';
};
interface ToolScrollableSectionProps {
children: React.ReactNode;
maxHeightClass?: string;
className?: string;
outerClassName?: string;
disableHorizontal?: boolean;
}
const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
children,
maxHeightClass = 'max-h-[60vh]',
className,
outerClassName,
disableHorizontal = false,
}) => (
<ScrollableOverlay
outerClassName={cn('w-full min-w-0 flex-none overflow-hidden', maxHeightClass, outerClassName)}
className={cn('p-2 rounded-xl w-full min-w-0 border border-border/20 bg-muted/30', className)}
disableHorizontal={disableHorizontal}
>
<div className="w-full min-w-0">
{children}
</div>
</ScrollableOverlay>
);
interface DiffPreviewProps {
diff: string;
syntaxTheme: { [key: string]: React.CSSProperties };
input?: ToolStateWithMetadata['input'];
}
const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) => (
<div className="typography-meta px-1 pb-1 pt-0 space-y-0">
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<div>
{hunk.lines.map((line, lineIdx) => (
<div
key={lineIdx}
className={cn(
'typography-meta font-mono px-2 py-0.5 flex -mx-2',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
)}
style={
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)' }
: {}
}
>
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-2 self-start select-none">
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(typeof input?.file_path === 'string' ? input.file_path : typeof input?.filePath === 'string' ? input.filePath : hunk.file) || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent !important' },
}}
>
{line.content}
</SyntaxHighlighter>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
interface WriteInputPreviewProps {
content: string;
syntaxTheme: { [key: string]: React.CSSProperties };
filePath?: string;
displayPath: string;
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTheme, filePath, displayPath }) => {
const lines = content.split('\n');
const language = getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined);
const lineCount = Math.max(lines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-1">
{`${displayPath} (${headerLineLabel})`}
</div>
<div className="space-y-0">
{lines.map((line, lineIdx) => (
<div key={lineIdx} className="typography-meta font-mono px-2 py-0.5 flex -mx-1">
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-2 self-start select-none">
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<SyntaxHighlighter
style={syntaxTheme}
language={language || 'text'}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent !important' },
}}
>
{line || ' '}
</SyntaxHighlighter>
</div>
</div>
))}
</div>
</div>
);
};
interface ToolExpandedContentProps {
part: ToolPartType;
state: ToolStateUnion;
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
currentDirectory: string;
hasPrevTool: boolean;
hasNextTool: boolean;
}
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
part,
state,
syntaxTheme,
isMobile,
currentDirectory,
hasPrevTool,
hasNextTool,
}) => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const rawOutput = stateWithData.output;
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
const outputString = typeof rawOutput === 'string' ? rawOutput : '';
const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
const writeFilePath = part.tool === 'write'
? typeof input?.filePath === 'string'
? input.filePath
: typeof input?.file_path === 'string'
? input.file_path
: typeof input?.path === 'string'
? input.path
: undefined
: undefined;
const writeInputContent = part.tool === 'write'
? typeof (input as { content?: unknown })?.content === 'string'
? (input as { content?: string }).content
: typeof (input as { text?: unknown })?.text === 'string'
? (input as { text?: string }).text
: null
: null;
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
const writeDisplayPath = shouldShowWriteInputPreview
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file')
: null;
const inputTextContent = React.useMemo(() => {
if (!input || typeof input !== 'object' || Object.keys(input).length === 0) {
return '';
}
if ('command' in input && typeof input.command === 'string' && part.tool === 'bash') {
return formatInputForDisplay(input, part.tool);
}
if (typeof (input as { content?: unknown }).content === 'string') {
return (input as { content?: string }).content ?? '';
}
return formatInputForDisplay(input, part.tool);
}, [input, part.tool]);
const hasInputText = inputTextContent.trim().length > 0;
const renderScrollableBlock = (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
) => (
<ToolScrollableSection
maxHeightClass={options?.maxHeightClass}
className={options?.className}
disableHorizontal={options?.disableHorizontal}
outerClassName={options?.outerClassName}
>
{content}
</ToolScrollableSection>
);
const renderResultContent = () => {
if (part.tool === 'todowrite' || part.tool === 'todoread') {
if (state.status === 'completed' && hasStringOutput) {
const todoContent = renderTodoOutput(outputString, { unstyled: true });
return renderScrollableBlock(
todoContent ?? (
<div className="typography-meta text-muted-foreground">Unable to parse todo list</div>
)
);
}
if (state.status === 'error' && 'error' in state) {
return (
<div>
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
);
}
return <div className="typography-meta text-muted-foreground">Processing todo list...</div>;
}
if (part.tool === 'list' && hasStringOutput) {
const listOutput = renderListOutput(outputString, { unstyled: true });
return renderScrollableBlock(
listOutput ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'grep' && hasStringOutput) {
const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true });
return renderScrollableBlock(
grepOutput ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'glob' && hasStringOutput) {
const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true });
return renderScrollableBlock(
globOutput ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'task' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{outputString}
</Streamdown>
</div>
);
}
if ((part.tool === 'web-search' || part.tool === 'websearch' || part.tool === 'search_web') && hasStringOutput) {
const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true });
return renderScrollableBlock(
webSearchContent ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'codesearch' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{outputString}
</Streamdown>
</div>
);
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) {
return renderScrollableBlock(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
{ className: 'p-1' }
);
}
if (hasStringOutput && outputString.trim()) {
if (part.tool === 'read') {
const formattedOutput = formatEditOutput(outputString, part.tool, metadata);
const lines = formattedOutput.split('\n');
const offset = typeof input?.offset === 'number' ? input.offset : 0;
const limit = typeof input?.limit === 'number' ? input.limit : undefined;
const isInfoMessage = (line: string) => line.trim().startsWith('(');
return renderScrollableBlock(
<div className="typography-meta w-full min-w-0 space-y-1">
{lines.map((line: string, idx: number) => {
const isInfo = isInfoMessage(line);
const lineNumber = offset + idx + 1;
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
return (
<div key={idx} className={cn('typography-meta font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-3 self-start select-none">
{shouldShowLineNumber ? lineNumber : ''}
</span>
<div className="flex-1 min-w-0">
{isInfo ? (
<div className="whitespace-pre-wrap break-words">{line}</div>
) : (
<SyntaxHighlighter
style={syntaxTheme}
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent !important',
},
}}
>
{line}
</SyntaxHighlighter>
)}
</div>
</div>
);
})}
</div>,
{ className: 'p-1' }
);
}
return renderScrollableBlock(
<SyntaxHighlighter
style={syntaxTheme}
language={detectLanguageFromOutput(formatEditOutput(outputString, part.tool, metadata), part.tool, input)}
PreTag="div"
customStyle={{
...toolDisplayStyles.getCollapsedStyles(),
padding: 0,
overflow: 'visible',
}}
codeTagProps={{
style: {
background: 'transparent !important',
},
}}
wrapLongLines
>
{formatEditOutput(outputString, part.tool, metadata)}
</SyntaxHighlighter>,
{ className: 'p-1' }
);
}
return renderScrollableBlock(
<div className="typography-meta text-muted-foreground/70">No output produced</div>,
{ maxHeightClass: 'max-h-60' }
);
};
return (
<div
className={cn(
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
hasPrevTool ? 'before:top-[-0.45rem]' : 'before:top-[-0.25rem]',
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
)}
>
{(part.tool === 'todowrite' || part.tool === 'todoread') ? (
renderResultContent()
) : (
<>
{shouldShowWriteInputPreview ? (
<div className="my-1">
{renderScrollableBlock(
<WriteInputPreview
content={writeInputContent as string}
syntaxTheme={syntaxTheme}
filePath={writeFilePath}
displayPath={writeDisplayPath ?? 'New file'}
/>
)}
</div>
) : hasInputText ? (
<div className="my-1">
{renderScrollableBlock(
<blockquote className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
{inputTextContent}
</blockquote>,
{ maxHeightClass: 'max-h-60' }
)}
</div>
) : null}
{part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
<div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">
Result:
</div>
{renderResultContent()}
</div>
)}
{state.status === 'error' && 'error' in state && (
<div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">Error:</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
)}
</>
)}
</div>
);
};
const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => {
const state = part.state;
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const isFinalized = state.status === 'completed' || state.status === 'error';
const isRunning = state.status === 'running';
const isError = state.status === 'error';
const [currentTime, setCurrentTime] = React.useState(Date.now());
React.useEffect(() => {
if (isRunning) {
const timer = setInterval(() => {
setCurrentTime(Date.now());
}, 100);
return () => clearInterval(timer);
}
}, [isRunning]);
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
React.useEffect(() => {
if (!isFinalized) {
return;
}
if (previousExpandedRef.current === isExpanded) {
return;
}
previousExpandedRef.current = isExpanded;
if (typeof isExpanded === 'boolean') {
onContentChange?.('structural');
}
}, [isExpanded, isFinalized, onContentChange]);
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null;
const description = getToolDescription(part, state, isMobile, currentDirectory);
const displayName = getToolMetadata(part.tool).displayName;
if (!isFinalized) {
return null;
}
return (
<div className="my-1">
{}
<div
className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
)}
onClick={() => onToggle(part.id)}
>
<div className="flex items-center gap-2 flex-shrink-0">
{}
<div className="relative h-3.5 w-3.5 flex-shrink-0">
{}
<div
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
)}
style={isError ? { color: 'var(--status-error)' } : {}}
>
{getToolIcon(part.tool)}
</div>
{}
<div
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && isMobile && 'opacity-0',
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
</div>
</div>
<span
className="typography-meta font-medium"
style={isError ? { color: 'var(--status-error)' } : {}}
>
{displayName}
</span>
</div>
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
{description && (
<span className={cn("truncate", isMobile && "max-w-[120px]")}>
{description}
</span>
)}
{diffStats && (
<span className="text-muted-foreground/60 flex-shrink-0">
<span style={{ color: 'var(--status-success)' }}>+{diffStats.added}</span>
{' '}
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
</span>
)}
{'time' in state && state.time && (
<span className="text-muted-foreground/80 flex-shrink-0">
{formatDuration(state.time.start, isFinalized && 'end' in state.time ? state.time.end : currentTime)}
</span>
)}
</div>
</div>
{}
{isExpanded && (
<ToolExpandedContent
part={part}
state={state}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
currentDirectory={currentDirectory}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
/>
)}
</div>
);
};
export default ToolPart;
@@ -0,0 +1,357 @@
import React from 'react';
import { Streamdown } from 'streamdown';
import { cn } from '@/lib/utils';
import type { Part } from '@opencode-ai/sdk';
import type { AgentMentionInfo } from '../types';
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
const SHIKI_THEMES = ['vitesse-light', 'vitesse-dark'] as const;
const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
const [copied, setCopied] = React.useState(false);
const codeRef = React.useRef<HTMLDivElement>(null);
const handleCopy = async () => {
if (!codeRef.current) return;
const codeEl = codeRef.current.querySelector('code');
const code = codeEl?.innerText || '';
if (!code) return;
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className={cn('group relative', className)} ref={codeRef}>
{children}
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
</div>
);
};
// Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = [];
const rows: string[][] = [];
const thead = tableEl.querySelector('thead');
if (thead) {
const headerCells = thead.querySelectorAll('th');
headerCells.forEach(cell => headers.push(cell.innerText.trim()));
}
const tbody = tableEl.querySelector('tbody');
if (tbody) {
const rowEls = tbody.querySelectorAll('tr');
rowEls.forEach(row => {
const cells = row.querySelectorAll('td');
const rowData: string[] = [];
cells.forEach(cell => rowData.push(cell.innerText.trim()));
rows.push(rowData);
});
}
return { headers, rows };
};
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
const escapeCell = (cell: string): string => {
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const lines: string[] = [];
if (headers.length > 0) {
lines.push(headers.map(escapeCell).join(','));
}
rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
return lines.join('\n');
};
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
const escapeCell = (cell: string): string => {
return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
};
const lines: string[] = [];
if (headers.length > 0) {
lines.push(headers.map(escapeCell).join('\t'));
}
rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
return lines.join('\n');
};
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
if (headers.length === 0) return '';
const escapeCell = (cell: string): string => {
return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
};
const lines: string[] = [];
lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
rows.forEach(row => {
const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
lines.push(`| ${paddedRow.join(' | ')} |`);
});
return lines.join('\n');
};
const downloadFile = (filename: string, content: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Table copy button with dropdown
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const [copied, setCopied] = React.useState(false);
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleCopy = async (format: 'csv' | 'tsv') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([content], { type: 'text/plain' }),
'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
}),
]);
setCopied(true);
setShowMenu(false);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy table:', err);
}
};
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy table"
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
{showMenu && (
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
onClick={() => handleCopy('csv')}
>
CSV
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
onClick={() => handleCopy('tsv')}
>
TSV
</button>
</div>
)}
</div>
);
};
// Table download button with dropdown
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
} catch (err) {
console.error('Failed to download table:', err);
}
};
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download table"
>
<RiDownloadLine className="size-3.5" />
</button>
{showMenu && (
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
onClick={() => handleDownload('csv')}
>
CSV
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
onClick={() => handleDownload('markdown')}
>
Markdown
</button>
</div>
)}
</div>
);
};
// Table wrapper with custom controls
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
const tableRef = React.useRef<HTMLDivElement>(null);
return (
<div className="group my-4 flex flex-col space-y-2" data-streamdown="table-wrapper" ref={tableRef}>
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<TableCopyButton tableRef={tableRef} />
<TableDownloadButton tableRef={tableRef} />
</div>
<div className="overflow-x-auto">
<table className={cn('w-full border-collapse border border-border', className)} data-streamdown="table">
{children}
</table>
</div>
</div>
);
};
const streamdownComponents = {
pre: CodeBlockWrapper,
table: TableWrapper,
};
type PartWithText = Part & { text?: string; content?: string; value?: string };
type UserTextPartProps = {
part: Part;
messageId: string;
isMobile: boolean;
agentMention?: AgentMentionInfo;
};
const buildMentionLink = (token: string, name: string): string => {
const encoded = encodeURIComponent(name);
return `[${token}](https://opencode.ai/docs/agents/#${encoded})`;
};
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile, agentMention }) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
const [isExpanded, setIsExpanded] = React.useState(false);
const [isTruncated, setIsTruncated] = React.useState(false);
const textRef = React.useRef<HTMLDivElement>(null);
const processedText = React.useMemo(() => {
if (!agentMention) {
return textContent;
}
const token = agentMention.token;
if (!token || token.length === 0) {
return textContent;
}
if (!textContent.includes(token)) {
return textContent;
}
const link = buildMentionLink(token, agentMention.name);
return textContent.replace(token, link);
}, [agentMention, textContent]);
React.useEffect(() => {
const el = textRef.current;
if (el && !isExpanded) {
setIsTruncated(el.scrollHeight > el.clientHeight);
}
}, [processedText, isExpanded]);
const handleClick = React.useCallback(() => {
if (isTruncated || isExpanded) {
setIsExpanded((prev) => !prev);
}
}, [isTruncated, isExpanded]);
if (!processedText || processedText.trim().length === 0) {
return null;
}
return (
<div
className={cn(
"break-words",
!isExpanded && "line-clamp-3",
(isTruncated || isExpanded) && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}
key={part.id || `${messageId}-user-text`}
>
<Streamdown
mode="static"
shikiTheme={SHIKI_THEMES}
className={cn('streamdown-content streamdown-user', isMobile && 'streamdown-mobile')}
controls={{ code: false, table: false }}
components={streamdownComponents}
>
{processedText}
</Streamdown>
</div>
);
};
export default React.memo(UserTextPart);
@@ -0,0 +1,404 @@
import React, { useState, useEffect, useRef } from 'react';
import { Text } from '@/components/ui/text';
interface WorkingPlaceholderProps {
statusText: string | null;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
completionId?: string | null;
isComplete?: boolean;
}
const MIN_DISPLAY_TIME = 2000;
const DONE_DISPLAY_TIME = 1500;
type ResultState = 'success' | 'aborted' | null;
export function WorkingPlaceholder({
statusText,
isWaitingForPermission,
wasAborted,
completionId,
isComplete,
}: WorkingPlaceholderProps) {
const [displayedStatus, setDisplayedStatus] = useState<string | null>(null);
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
const [isVisible, setIsVisible] = useState<boolean>(false);
const [isFadingOut, setIsFadingOut] = useState<boolean>(false);
const [resultState, setResultState] = useState<ResultState>(null);
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
const displayStartTimeRef = useRef<number>(0);
const statusQueueRef = useRef<Array<{ status: string; permission: boolean }>>([]);
const removalPendingRef = useRef<boolean>(false);
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resultTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastActiveStatusRef = useRef<string | null>(null);
const hasShownActivityRef = useRef<boolean>(false);
const wasAbortedRef = useRef<boolean>(false);
const isCompleteRef = useRef<boolean>(false);
const windowFocusRef = useRef<boolean>(true);
const lastCompletionShownRef = useRef<string | null>(null);
const resultShownAtRef = useRef<number | null>(null);
const activateStatus = (status: string, permission: boolean) => {
if (fadeTimeoutRef.current) {
clearTimeout(fadeTimeoutRef.current);
fadeTimeoutRef.current = null;
}
if (resultTimeoutRef.current) {
clearTimeout(resultTimeoutRef.current);
resultTimeoutRef.current = null;
}
if (transitionTimeoutRef.current) {
clearTimeout(transitionTimeoutRef.current);
transitionTimeoutRef.current = null;
}
if (status === 'aborted') {
setDisplayedStatus(null);
setDisplayedPermission(false);
setIsFadingOut(false);
setResultState('aborted');
setIsTransitioning(false);
lastActiveStatusRef.current = 'aborted';
hasShownActivityRef.current = true;
wasAbortedRef.current = true;
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => setIsVisible(true));
} else {
setIsVisible(true);
}
return;
}
setResultState(null);
setIsFadingOut(false);
lastActiveStatusRef.current = status;
hasShownActivityRef.current = true;
const isStatusChanging = displayedStatus !== null && displayedStatus !== status;
if (isStatusChanging) {
setIsTransitioning(true);
transitionTimeoutRef.current = setTimeout(() => {
setIsTransitioning(false);
transitionTimeoutRef.current = null;
}, 150);
}
setDisplayedStatus(status);
setDisplayedPermission(permission);
if (!isVisible) {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
setIsVisible(true);
});
} else {
setIsVisible(true);
}
}
};
useEffect(() => {
const now = Date.now();
if (statusText) {
removalPendingRef.current = false;
if (!displayedStatus) {
activateStatus(statusText, !!isWaitingForPermission);
displayStartTimeRef.current = now;
statusQueueRef.current = [];
} else if (
statusText !== displayedStatus ||
!!isWaitingForPermission !== displayedPermission
) {
statusQueueRef.current.push({
status: statusText,
permission: !!isWaitingForPermission,
});
}
} else {
removalPendingRef.current = true;
}
}, [statusText, isWaitingForPermission, displayedStatus, displayedPermission, wasAborted]);
useEffect(() => {
if (wasAborted) {
wasAbortedRef.current = true;
}
}, [wasAborted]);
useEffect(() => {
isCompleteRef.current = !!isComplete;
}, [isComplete]);
useEffect(() => {
if (isComplete) {
removalPendingRef.current = true;
}
}, [isComplete]);
useEffect(() => {
const startFadeOut = (result: ResultState) => {
if (isFadingOut) {
return;
}
const hadActiveStatus =
lastActiveStatusRef.current !== null || hasShownActivityRef.current;
if (result && hadActiveStatus) {
setIsFadingOut(false);
setIsVisible(true);
setDisplayedStatus(null);
setDisplayedPermission(false);
setResultState(result);
lastActiveStatusRef.current = null;
setIsTransitioning(false);
if (result === 'success' && completionId) {
lastCompletionShownRef.current = completionId;
}
resultShownAtRef.current = Date.now();
if (resultTimeoutRef.current) {
clearTimeout(resultTimeoutRef.current);
}
resultTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
setResultState(null);
hasShownActivityRef.current = false;
resultTimeoutRef.current = null;
}, DONE_DISPLAY_TIME);
} else {
setIsFadingOut(true);
setIsVisible(false);
setResultState(null);
if (fadeTimeoutRef.current) {
clearTimeout(fadeTimeoutRef.current);
}
fadeTimeoutRef.current = setTimeout(() => {
setDisplayedStatus(null);
setDisplayedPermission(false);
setIsFadingOut(false);
hasShownActivityRef.current = false;
lastActiveStatusRef.current = null;
fadeTimeoutRef.current = null;
}, 180);
}
wasAbortedRef.current = false;
};
const checkInterval = setInterval(() => {
const now = Date.now();
const elapsed = now - displayStartTimeRef.current;
const isDone = removalPendingRef.current && isCompleteRef.current;
const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0;
if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) {
return;
}
if (removalPendingRef.current && wasAbortedRef.current) {
removalPendingRef.current = false;
statusQueueRef.current = [];
startFadeOut('aborted');
} else if (!isDone && statusQueueRef.current.length > 0) {
const latest = statusQueueRef.current[statusQueueRef.current.length - 1];
activateStatus(latest.status, latest.permission);
displayStartTimeRef.current = now;
statusQueueRef.current = [];
} else if (removalPendingRef.current) {
removalPendingRef.current = false;
if (statusQueueRef.current.length > 0) {
hasShownActivityRef.current = true;
}
statusQueueRef.current = [];
let result: ResultState = null;
if (wasAbortedRef.current) {
result = 'aborted';
} else if (isCompleteRef.current) {
result = 'success';
hasShownActivityRef.current = true;
}
if (result === 'success' && completionId && lastCompletionShownRef.current === completionId) {
setDisplayedStatus(null);
setDisplayedPermission(false);
setIsFadingOut(false);
setIsVisible(false);
setResultState(null);
statusQueueRef.current = [];
hasShownActivityRef.current = false;
lastActiveStatusRef.current = null;
removalPendingRef.current = false;
wasAbortedRef.current = false;
return;
}
startFadeOut(result);
}
}, 50);
return () => clearInterval(checkInterval);
}, [isFadingOut]);
useEffect(() => {
return () => {
if (fadeTimeoutRef.current) {
clearTimeout(fadeTimeoutRef.current);
}
if (resultTimeoutRef.current) {
clearTimeout(resultTimeoutRef.current);
}
if (transitionTimeoutRef.current) {
clearTimeout(transitionTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
windowFocusRef.current = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
? document.hasFocus()
: true;
const handleFocus = () => {
windowFocusRef.current = true;
};
const handleBlur = () => {
windowFocusRef.current = false;
};
window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
};
}, []);
useEffect(() => {
const handleVisibilityRestore = () => {
if (typeof document === 'undefined' || typeof Date === 'undefined') {
return;
}
if (document.visibilityState !== 'visible') {
return;
}
const shownAt = resultShownAtRef.current;
const isCompletionVisible = resultState !== null || displayedStatus !== null;
if (isCompletionVisible && shownAt && Date.now() - shownAt > 500) {
setDisplayedStatus(null);
setDisplayedPermission(false);
setIsFadingOut(false);
setIsVisible(false);
setResultState(null);
statusQueueRef.current = [];
hasShownActivityRef.current = false;
lastActiveStatusRef.current = null;
removalPendingRef.current = false;
wasAbortedRef.current = false;
}
};
document.addEventListener('visibilitychange', handleVisibilityRestore);
window.addEventListener('focus', handleVisibilityRestore);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityRestore);
window.removeEventListener('focus', handleVisibilityRestore);
};
}, [displayedStatus, resultState]);
if (!displayedStatus && resultState === null) {
return null;
}
let label: string;
if (resultState === 'success') {
label = 'Done';
} else if (resultState === 'aborted') {
label = 'Aborted';
} else if (displayedStatus) {
label = displayedStatus.charAt(0).toUpperCase() + displayedStatus.slice(1);
} else {
label = 'Working';
}
const ariaLive = displayedPermission ? 'assertive' : 'polite';
const displayText = resultState === null ? `${label}...` : label;
return (
<div
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible && !isFadingOut ? 'opacity-100' : 'opacity-0'}`}
role="status"
aria-live={ariaLive}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
{resultState === null && (
<Text
variant="shine"
className="typography-ui-header transition-opacity duration-150"
style={{ opacity: isTransitioning ? 0.6 : 1 }}
>
{displayText}
</Text>
)}
{resultState === 'success' && (
<Text
variant="hover-enter"
className="typography-ui-header transition-opacity duration-150"
style={{ opacity: isTransitioning ? 0.6 : 1 }}
>
Done
</Text>
)}
{resultState === 'aborted' && (
<Text
variant="hover-enter"
className="typography-ui-header transition-opacity duration-150 text-status-error"
style={{ opacity: isTransitioning ? 0.6 : 1 }}
>
Aborted
</Text>
)}
</span>
</div>
);
}
@@ -0,0 +1,642 @@
import { Streamdown } from 'streamdown';
import { RiCheckLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { typography } from '@/lib/typography';
import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers';
const cleanOutput = (output: string) => {
let cleaned = output.replace(/^<file>\s*\n?/, '').replace(/\n?<\/file>\s*$/, '');
cleaned = cleaned.replace(/^\s*\d{5}\|\s?/gm, '');
return cleaned.trim();
};
export const hasLspDiagnostics = (output: string): boolean => {
if (!output) return false;
return output.includes('<file_diagnostics>') || output.includes('This file has errors') || output.includes('please fix');
};
const stripLspDiagnostics = (output: string): string => {
if (!output) return '';
return output.replace(/This file has errors.*?<\/file_diagnostics>/s, '').trim();
};
const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string) => {
if (!input || typeof input !== 'object') {
return String(input);
}
return formatToolInput(input, toolName || '');
};
export const formatEditOutput = (output: string, toolName: string, metadata?: Record<string, unknown>): string => {
let cleaned = cleanOutput(output);
if ((toolName === 'edit' || toolName === 'multiedit') && hasLspDiagnostics(cleaned)) {
cleaned = stripLspDiagnostics(cleaned);
}
if ((toolName === 'edit' || toolName === 'multiedit') && cleaned.trim().length === 0 && metadata?.diff) {
const diff = metadata.diff;
return typeof diff === 'string' ? diff : String(diff);
}
return cleaned;
};
export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
try {
const lines = output.trim().split('\n').filter(Boolean);
if (lines.length === 0) return null;
const items: Array<{ name: string; depth: number; isFile: boolean }> = [];
lines.forEach((line) => {
const match = line.match(/^(\s*)(.+)$/);
if (match) {
const [, spaces, name] = match;
const depth = Math.floor(spaces.length / 2);
const isFile = !name.endsWith('/');
items.push({
name: name.replace(/\/$/, ''),
depth,
isFile,
});
}
});
return (
<div
className={cn(
'w-full min-w-0 font-mono space-y-0.5',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
style={typography.micro}
>
{items.map((item, idx) => (
<div key={idx} className="min-w-0" style={{ paddingLeft: `${item.depth * 20}px` }}>
{item.isFile ? (
<span className="text-foreground/90 block truncate">{item.name}</span>
) : (
<span className="font-semibold text-foreground block truncate">{item.name}/</span>
)}
</div>
))}
</div>
);
} catch {
return null;
}
};
export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
try {
const lines = output.trim().split('\n').filter(Boolean);
if (lines.length === 0) return null;
const fileGroups: Record<string, Array<{ lineNum: string; content: string }>> = {};
lines.forEach((line) => {
const match = line.match(/^(.+?):(\d+):(.*)$/) || line.match(/^(.+?):(.*)$/);
if (match) {
const [, filepath, lineNumOrContent, content] = match;
const lineNum = content !== undefined ? lineNumOrContent : '';
const actualContent = content !== undefined ? content : lineNumOrContent;
if (!fileGroups[filepath]) {
fileGroups[filepath] = [];
}
fileGroups[filepath].push({ lineNum, content: actualContent });
}
});
return (
<div
className={cn(
'space-y-2 w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
>
<div className="typography-meta text-muted-foreground mb-2">
Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
</div>
{Object.entries(fileGroups).map(([filepath, matches]) => (
<div key={filepath} className="space-y-1">
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
{filepath}
</div>
<div className="pl-4 space-y-1">
{matches.map((match, idx) => {
if (!match.lineNum && !match.content) {
return null;
}
return (
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
<div className="flex gap-2 min-w-0 flex-1">
{match.lineNum && (
<span className="text-muted-foreground font-mono whitespace-nowrap">
Line {match.lineNum}:
</span>
)}
<span className="text-foreground font-mono break-words flex-1">
{match.content || '\u00A0'}
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
} catch {
return null;
}
};
export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
try {
const paths = output.trim().split('\n').filter(Boolean);
if (paths.length === 0) return null;
const groups: Record<string, string[]> = {};
paths.forEach((path) => {
const lastSlash = path.lastIndexOf('/');
const dir = lastSlash > 0 ? path.substring(0, lastSlash) : '/';
const filename = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
if (!groups[dir]) {
groups[dir] = [];
}
groups[dir].push(filename);
});
const sortedDirs = Object.keys(groups).sort();
return (
<div
className={cn(
'space-y-2 w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
>
<div className="typography-meta text-muted-foreground mb-2">
Found {paths.length} file{paths.length !== 1 ? 's' : ''}
</div>
{sortedDirs.map((dir) => (
<div key={dir} className="space-y-1">
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
{dir}/
</div>
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
{groups[dir].sort().map((filename) => (
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
<span className="text-foreground font-mono truncate">{filename}</span>
</div>
))}
</div>
</div>
))}
</div>
);
} catch {
return null;
}
};
type Todo = {
id?: string;
content: string;
status: 'in_progress' | 'pending' | 'completed' | 'cancelled';
priority?: 'high' | 'medium' | 'low';
};
export const renderTodoOutput = (output: string, options?: { unstyled?: boolean }) => {
try {
const todos = JSON.parse(output) as Todo[];
if (!Array.isArray(todos)) {
return null;
}
const todosByStatus = {
in_progress: todos.filter((t) => t.status === 'in_progress'),
pending: todos.filter((t) => t.status === 'pending'),
completed: todos.filter((t) => t.status === 'completed'),
cancelled: todos.filter((t) => t.status === 'cancelled'),
};
const getPriorityDot = (priority?: string) => {
const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1';
switch (priority) {
case 'high':
return <div className={baseClasses} style={{ backgroundColor: 'var(--status-error)' }} />;
case 'medium':
return <div className={baseClasses} style={{ backgroundColor: 'var(--primary)' }} />;
case 'low':
return <div className={baseClasses} style={{ backgroundColor: 'var(--status-info)' }} />;
default:
return <div className={baseClasses} style={{ backgroundColor: 'var(--muted-foreground)', opacity: 0.5 }} />;
}
};
return (
<div
className={cn(
'space-y-3 w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
>
<div className="flex gap-4 typography-meta pb-2 border-b border-border/20">
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>Total: {todos.length}</span>
{todosByStatus.in_progress.length > 0 && (
<span className="font-medium" style={{ color: 'var(--foreground)' }}>In Progress: {todosByStatus.in_progress.length}</span>
)}
{todosByStatus.pending.length > 0 && (
<span style={{ color: 'var(--muted-foreground)' }}>Pending: {todosByStatus.pending.length}</span>
)}
{todosByStatus.completed.length > 0 && (
<span style={{ color: 'var(--status-success)' }}>Completed: {todosByStatus.completed.length}</span>
)}
{todosByStatus.cancelled.length > 0 && (
<span style={{ color: 'var(--muted-foreground)', opacity: 0.5 }}>Cancelled: {todosByStatus.cancelled.length}</span>
)}
</div>
{todosByStatus.in_progress.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: 'var(--foreground)' }} />
<span className="typography-meta font-semibold text-foreground uppercase tracking-wide">In Progress</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.in_progress.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
{getPriorityDot(todo.priority)}
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
</div>
)}
{todosByStatus.pending.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-muted-foreground/50" />
<span className="typography-meta font-semibold text-muted-foreground uppercase tracking-wide">Pending</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.pending.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
{getPriorityDot(todo.priority)}
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
</div>
)}
{todosByStatus.completed.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<RiCheckLine className="w-3 h-3" style={{ color: 'var(--status-success)' }} />
<span className="typography-meta font-semibold uppercase tracking-wide" style={{ color: 'var(--status-success)' }}>Completed</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.completed.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
<RiCheckLine className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--status-success)', opacity: 0.7 }} />
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
</div>
)}
{todosByStatus.cancelled.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-3 h-3 text-muted-foreground/50">×</span>
<span className="typography-meta font-semibold text-muted-foreground/50 uppercase tracking-wide">Cancelled</span>
</div>
<div className="space-y-1.5 pl-4">
{todosByStatus.cancelled.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
<span className="w-3 h-3 text-muted-foreground/50 mt-0.5 flex-shrink-0">×</span>
<span className="typography-meta text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
</div>
)}
</div>
);
} catch {
return null;
}
};
export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: string]: React.CSSProperties }, options?: { unstyled?: boolean }) => {
try {
return (
<div
className={cn(
'typography-meta max-w-none w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/20'
)}
>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{output}
</Streamdown>
</div>
);
} catch {
return null;
}
};
export type DiffLineType = 'context' | 'added' | 'removed';
export interface UnifiedDiffLine {
type: DiffLineType;
lineNumber: number | null;
content: string;
}
export interface UnifiedDiffHunk {
file: string;
oldStart: number;
newStart: number;
lines: UnifiedDiffLine[];
}
export interface SideBySideDiffLine {
leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
}
export interface SideBySideDiffHunk {
file: string;
oldStart: number;
newStart: number;
lines: SideBySideDiffLine[];
}
export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
const lines = diffText.split('\n');
let currentFile = '';
const hunks: UnifiedDiffHunk[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
if (line.startsWith('Index:')) {
currentFile = line.split(' ')[1].split('/').pop() || 'file';
}
i++;
continue;
}
if (line.startsWith('@@')) {
const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
const oldStart = match ? parseInt(match[1]) : 0;
const newStart = match ? parseInt(match[2]) : 0;
const unifiedLines: UnifiedDiffLine[] = [];
let lineNum = newStart;
let j = i + 1;
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
const contentLine = lines[j];
if (contentLine.startsWith('+')) {
unifiedLines.push({ type: 'added', lineNumber: lineNum, content: contentLine.substring(1) });
lineNum++;
} else if (contentLine.startsWith('-')) {
unifiedLines.push({ type: 'removed', lineNumber: null, content: contentLine.substring(1) });
} else if (contentLine.startsWith(' ')) {
unifiedLines.push({ type: 'context', lineNumber: lineNum, content: contentLine.substring(1) });
lineNum++;
}
j++;
}
hunks.push({
file: currentFile,
oldStart,
newStart,
lines: unifiedLines,
});
i = j;
continue;
}
i++;
}
return hunks;
};
export const parseDiffToLines = (diffText: string): SideBySideDiffHunk[] => {
const lines = diffText.split('\n');
let currentFile = '';
const hunks: SideBySideDiffHunk[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
if (line.startsWith('Index:')) {
currentFile = line.split(' ')[1].split('/').pop() || 'file';
}
i++;
continue;
}
if (line.startsWith('@@')) {
const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
const oldStart = match ? parseInt(match[1]) : 0;
const newStart = match ? parseInt(match[2]) : 0;
const changes: Array<{
type: 'context' | 'added' | 'removed';
content: string;
oldLine?: number;
newLine?: number;
}> = [];
let oldLineNum = oldStart;
let newLineNum = newStart;
let j = i + 1;
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
const contentLine = lines[j];
if (contentLine.startsWith('+')) {
changes.push({ type: 'added', content: contentLine.substring(1), newLine: newLineNum });
newLineNum++;
} else if (contentLine.startsWith('-')) {
changes.push({ type: 'removed', content: contentLine.substring(1), oldLine: oldLineNum });
oldLineNum++;
} else if (contentLine.startsWith(' ')) {
changes.push({
type: 'context',
content: contentLine.substring(1),
oldLine: oldLineNum,
newLine: newLineNum,
});
oldLineNum++;
newLineNum++;
}
j++;
}
const alignedLines: Array<{
leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
}> = [];
const leftSide: Array<{ type: 'context' | 'removed'; lineNumber: number; content: string }> = [];
const rightSide: Array<{ type: 'context' | 'added'; lineNumber: number; content: string }> = [];
changes.forEach((change) => {
if (change.type === 'context') {
leftSide.push({ type: 'context', lineNumber: change.oldLine!, content: change.content });
rightSide.push({ type: 'context', lineNumber: change.newLine!, content: change.content });
} else if (change.type === 'removed') {
leftSide.push({ type: 'removed', lineNumber: change.oldLine!, content: change.content });
} else if (change.type === 'added') {
rightSide.push({ type: 'added', lineNumber: change.newLine!, content: change.content });
}
});
const alignmentPoints: Array<{ leftIdx: number; rightIdx: number }> = [];
leftSide.forEach((leftItem, leftIdx) => {
if (leftItem.type === 'context') {
const rightIdx = rightSide.findIndex((rightItem, rIdx) =>
rightItem.type === 'context' &&
rightItem.content === leftItem.content &&
!alignmentPoints.some((ap) => ap.rightIdx === rIdx)
);
if (rightIdx >= 0) {
alignmentPoints.push({ leftIdx, rightIdx });
}
}
});
alignmentPoints.sort((a, b) => a.leftIdx - b.leftIdx);
let leftIdx = 0;
let rightIdx = 0;
let alignIdx = 0;
while (leftIdx < leftSide.length || rightIdx < rightSide.length) {
const nextAlign = alignIdx < alignmentPoints.length ? alignmentPoints[alignIdx] : null;
if (nextAlign && leftIdx === nextAlign.leftIdx && rightIdx === nextAlign.rightIdx) {
const leftItem = leftSide[leftIdx];
const rightItem = rightSide[rightIdx];
alignedLines.push({
leftLine: {
type: 'context',
lineNumber: leftItem.lineNumber,
content: leftItem.content,
},
rightLine: {
type: 'context',
lineNumber: rightItem.lineNumber,
content: rightItem.content,
},
});
leftIdx++;
rightIdx++;
alignIdx++;
} else {
const needProcessLeft = leftIdx < leftSide.length && (!nextAlign || leftIdx < nextAlign.leftIdx);
const needProcessRight = rightIdx < rightSide.length && (!nextAlign || rightIdx < nextAlign.rightIdx);
if (needProcessLeft && needProcessRight) {
const leftItem = leftSide[leftIdx];
const rightItem = rightSide[rightIdx];
alignedLines.push({
leftLine: {
type: leftItem.type,
lineNumber: leftItem.lineNumber,
content: leftItem.content,
},
rightLine: {
type: rightItem.type,
lineNumber: rightItem.lineNumber,
content: rightItem.content,
},
});
leftIdx++;
rightIdx++;
} else if (needProcessLeft) {
const leftItem = leftSide[leftIdx];
alignedLines.push({
leftLine: {
type: leftItem.type,
lineNumber: leftItem.lineNumber,
content: leftItem.content,
},
rightLine: {
type: 'empty',
lineNumber: null,
content: '',
},
});
leftIdx++;
} else if (needProcessRight) {
const rightItem = rightSide[rightIdx];
alignedLines.push({
leftLine: {
type: 'empty',
lineNumber: null,
content: '',
},
rightLine: {
type: rightItem.type,
lineNumber: rightItem.lineNumber,
content: rightItem.content,
},
});
rightIdx++;
} else {
break;
}
}
}
hunks.push({
file: currentFile,
oldStart,
newStart,
lines: alignedLines,
});
i = j;
continue;
}
i++;
}
return hunks;
};
export const detectLanguageFromOutput = (output: string, toolName: string, input?: Record<string, unknown>) => {
return detectToolOutputLanguage(toolName, output, input);
};
export { formatInputForDisplay };
@@ -0,0 +1,24 @@
export type StreamPhase = 'streaming' | 'cooldown' | 'completed';
export type DiffViewMode = 'side-by-side' | 'unified';
export interface AgentMentionInfo {
name: string;
token: string;
}
export interface ToolPopupContent {
open: boolean;
title: string;
content: string;
language?: string;
isDiff?: boolean;
diffHunks?: Array<Record<string, unknown>>;
metadata?: Record<string, unknown>;
image?: {
url: string;
mimeType?: string;
filename?: string;
size?: number;
};
}