feat: improve chat streaming UX and add Mermaid diagram rendering (#438)
* feat: show current branch in empty chat state * fix: display current git branch for worktrees and update them with branch change * refactor: improve read tool output parsing with structured data * feat: add support for message part delta events * fix(chat): improve streaming rendering, scroll behavior, and assistant action visibility * feat: Add mermaid diagram support to chat markdown rendering * fix: update table download functionality to include success notification and remove unused MarkdownRenderer import * refactor: streamline Streamdown component props for improved readability * fix: preserve Streamdown code-block markers and use native Tauri cache clearing * feat: add context overview panel to view conversation details
This commit is contained in:
committed by
GitHub
parent
138772e66e
commit
4d71bb27eb
@@ -341,10 +341,10 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
if (!shouldSkipScroll) {
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom();
|
||||
scrollToBottom({ instant: true });
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
scrollToBottom({ instant: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ export const ChatContainer: React.FC = () => {
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState />
|
||||
<ChatEmptyState showDraftContext />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react';
|
||||
import { RiGitBranchLine } from '@remixicon/react';
|
||||
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { TextLoop } from '@/components/ui/TextLoop';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
|
||||
const phrases = [
|
||||
"Fix the failing tests",
|
||||
@@ -23,15 +27,51 @@ const phrases = [
|
||||
"Add type definitions",
|
||||
];
|
||||
|
||||
const ChatEmptyState: React.FC = () => {
|
||||
interface ChatEmptyStateProps {
|
||||
showDraftContext?: boolean;
|
||||
}
|
||||
|
||||
const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
|
||||
showDraftContext = false,
|
||||
}) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const { setActiveDirectory, fetchStatus } = useGitStore();
|
||||
const gitStatus = useGitStatus(effectiveDirectory ?? null);
|
||||
|
||||
// Use theme's muted foreground for secondary text
|
||||
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
|
||||
const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
|
||||
? gitStatus.current.trim()
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftContext || !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDirectory(effectiveDirectory);
|
||||
|
||||
const state = useGitStore.getState().directories.get(effectiveDirectory);
|
||||
if (!state?.status && state?.isGitRepo !== false) {
|
||||
void fetchStatus(effectiveDirectory, git, { silent: true });
|
||||
}
|
||||
}, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
{showDraftContext && (
|
||||
<div className="max-w-[calc(100%-2rem)] flex flex-col items-center gap-1">
|
||||
{branchName && (
|
||||
<div className="inline-flex items-center gap-1 text-body-md" style={{ color: textColor }}>
|
||||
<RiGitBranchLine className="h-4 w-4 shrink-0" />
|
||||
<span className="overflow-hidden whitespace-nowrap" title={branchName}>{branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TextLoop
|
||||
className="text-body-md"
|
||||
interval={4}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { code } from '@streamdown/code';
|
||||
import { mermaid } from '@streamdown/mermaid';
|
||||
import 'streamdown/styles.css';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -116,6 +120,45 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] =
|
||||
return isVSCode ? themes : fallbackThemes;
|
||||
};
|
||||
|
||||
const useStreamdownMermaidOptions = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
const fallbackLight = getDefaultTheme(false);
|
||||
const fallbackDark = getDefaultTheme(true);
|
||||
|
||||
const currentTheme = themeSystem?.currentTheme
|
||||
?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? fallbackDark
|
||||
: fallbackLight);
|
||||
|
||||
const mermaidRenderKey = `${currentTheme.metadata.id}:${currentTheme.metadata.variant}:${themeSystem?.themeMode ?? 'fallback'}`;
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const isDark = currentTheme.metadata.variant === 'dark';
|
||||
return {
|
||||
config: {
|
||||
theme: isDark ? 'dark' : 'base',
|
||||
themeVariables: {
|
||||
primaryColor: currentTheme.colors.surface.elevated,
|
||||
primaryTextColor: currentTheme.colors.surface.foreground,
|
||||
primaryBorderColor: currentTheme.colors.interactive.border,
|
||||
lineColor: currentTheme.colors.interactive.border,
|
||||
secondaryColor: currentTheme.colors.surface.muted,
|
||||
tertiaryColor: currentTheme.colors.surface.subtle,
|
||||
background: currentTheme.colors.surface.background,
|
||||
mainBkg: currentTheme.colors.surface.elevated,
|
||||
nodeTextColor: currentTheme.colors.surface.foreground,
|
||||
edgeLabelBackground: currentTheme.colors.surface.background,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [currentTheme]);
|
||||
|
||||
return React.useMemo(
|
||||
() => ({ options, mermaidRenderKey }),
|
||||
[mermaidRenderKey, options],
|
||||
);
|
||||
};
|
||||
|
||||
// Table utility functions
|
||||
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
||||
const headers: string[] = [];
|
||||
@@ -280,21 +323,22 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
|
||||
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);
|
||||
}
|
||||
};
|
||||
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);
|
||||
toast.success(`Table downloaded as ${format.toUpperCase()}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to download table:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
@@ -348,9 +392,35 @@ type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const getMermaidInfo = (children: React.ReactNode): { isMermaid: boolean; source: string } => {
|
||||
if (!React.isValidElement(children)) return { isMermaid: false, source: '' };
|
||||
const props = children.props as Record<string, unknown> | undefined;
|
||||
const className = typeof props?.className === 'string' ? props.className : '';
|
||||
if (!className.includes('language-mermaid')) return { isMermaid: false, source: '' };
|
||||
// Extract raw mermaid source from the code element's children
|
||||
const codeChildren = props?.children;
|
||||
let source = '';
|
||||
if (typeof codeChildren === 'string') {
|
||||
source = codeChildren;
|
||||
} else if (React.isValidElement(codeChildren)) {
|
||||
const innerProps = codeChildren.props as Record<string, unknown> | undefined;
|
||||
if (typeof innerProps?.children === 'string') source = innerProps.children;
|
||||
}
|
||||
return { isMermaid: true, source };
|
||||
};
|
||||
|
||||
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
const mermaidInfo = getMermaidInfo(children);
|
||||
const codeChild = React.useMemo(
|
||||
() => (
|
||||
React.isValidElement(children)
|
||||
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, { 'data-block': true })
|
||||
: children
|
||||
),
|
||||
[children],
|
||||
);
|
||||
|
||||
const normalizedStyle = React.useMemo<React.CSSProperties | undefined>(() => {
|
||||
if (!style) return style;
|
||||
@@ -396,6 +466,11 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
||||
return next;
|
||||
}, [style]);
|
||||
|
||||
// Mermaid blocks get their own controls via MermaidWrapper — skip the code copy button.
|
||||
if (mermaidInfo.isMermaid) {
|
||||
return <MermaidWrapper source={mermaidInfo.source}>{codeChild}</MermaidWrapper>;
|
||||
}
|
||||
|
||||
const getCodeContent = (): string => {
|
||||
if (!codeRef.current) return '';
|
||||
const codeEl = codeRef.current.querySelector('code');
|
||||
@@ -422,7 +497,7 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
||||
className={cn(className)}
|
||||
style={normalizedStyle}
|
||||
>
|
||||
{children}
|
||||
{codeChild}
|
||||
</pre>
|
||||
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@@ -442,6 +517,88 @@ const streamdownComponents = {
|
||||
table: TableWrapper,
|
||||
};
|
||||
|
||||
const streamdownPlugins = {
|
||||
code,
|
||||
mermaid,
|
||||
};
|
||||
|
||||
const streamdownControls = {
|
||||
code: false,
|
||||
table: false,
|
||||
mermaid: {
|
||||
download: false,
|
||||
copy: false,
|
||||
fullscreen: false,
|
||||
panZoom: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Mermaid copy button — copies raw mermaid source
|
||||
const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!source) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(source);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diagram:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy diagram source"
|
||||
>
|
||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Mermaid download button — downloads rendered SVG
|
||||
const MermaidDownloadButton: React.FC<{ containerRef: React.RefObject<HTMLDivElement | null> }> = ({ containerRef }) => {
|
||||
const handleDownload = () => {
|
||||
const svgEl = containerRef.current?.querySelector('svg');
|
||||
if (!(svgEl instanceof SVGElement)) return;
|
||||
const serializer = new XMLSerializer();
|
||||
let markup = serializer.serializeToString(svgEl);
|
||||
if (!markup.includes('xmlns=')) {
|
||||
markup = markup.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
downloadFile('diagram.svg', markup, 'image/svg+xml');
|
||||
toast.success('Diagram downloaded');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Download diagram"
|
||||
>
|
||||
<RiDownloadLine className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Mermaid wrapper with custom controls (same pattern as TableWrapper)
|
||||
const MermaidWrapper: React.FC<{ children: React.ReactNode; source: string }> = ({ children, source }) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="group relative" ref={containerRef}>
|
||||
{children}
|
||||
<div className="absolute top-1 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MermaidDownloadButton containerRef={containerRef} />
|
||||
<MermaidCopyButton source={source} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type MarkdownVariant = 'assistant' | 'tool';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
@@ -464,6 +621,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
variant = 'assistant',
|
||||
}) => {
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
|
||||
const componentKey = React.useMemo(() => {
|
||||
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
|
||||
return `markdown-${signature}`;
|
||||
@@ -476,12 +634,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Streamdown
|
||||
mode={isStreaming ? 'streaming' : 'static'}
|
||||
shikiTheme={shikiThemes}
|
||||
className={streamdownClassName}
|
||||
controls={{ code: false, table: false }}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
key={`streamdown-${componentKey}-${mermaidRenderKey}`}
|
||||
mode={isStreaming ? 'streaming' : 'static'}
|
||||
shikiTheme={shikiThemes}
|
||||
className={streamdownClassName}
|
||||
controls={streamdownControls}
|
||||
plugins={streamdownPlugins}
|
||||
mermaid={mermaidOptions}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
@@ -504,6 +665,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
||||
variant?: MarkdownVariant;
|
||||
}> = ({ content, className, variant = 'assistant' }) => {
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
|
||||
|
||||
const streamdownClassName = variant === 'tool'
|
||||
? 'streamdown-content streamdown-tool'
|
||||
@@ -512,10 +674,13 @@ export const SimpleMarkdownRenderer: React.FC<{
|
||||
return (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Streamdown
|
||||
key={`streamdown-simple-${mermaidRenderKey}`}
|
||||
mode="static"
|
||||
shikiTheme={shikiThemes}
|
||||
className={streamdownClassName}
|
||||
controls={{ code: false, table: false }}
|
||||
controls={streamdownControls}
|
||||
plugins={streamdownPlugins}
|
||||
mermaid={mermaidOptions}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -243,19 +243,25 @@ const isActivityStandaloneTool = (toolName: unknown): boolean => {
|
||||
};
|
||||
|
||||
const extractFinalAssistantText = (turn: Turn): string | undefined => {
|
||||
for (const assistantMsg of turn.assistantMessages) {
|
||||
for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const assistantMsg = turn.assistantMessages[messageIndex];
|
||||
if (!assistantMsg) continue;
|
||||
|
||||
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
|
||||
if (infoFinish === 'stop') {
|
||||
const textPart = assistantMsg.parts.find(p => p.type === 'text');
|
||||
if (textPart) {
|
||||
const textContent = (textPart as { text?: string | null | undefined }).text ??
|
||||
(textPart as { content?: string | null | undefined }).content;
|
||||
if (typeof textContent === 'string' && textContent.trim().length > 0) {
|
||||
return textContent;
|
||||
}
|
||||
if (infoFinish !== 'stop') continue;
|
||||
|
||||
for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
||||
const part = assistantMsg.parts[partIndex];
|
||||
if (!part || part.type !== 'text') continue;
|
||||
|
||||
const textContent = (part as { text?: string | null | undefined }).text ??
|
||||
(part as { content?: string | null | undefined }).content;
|
||||
if (typeof textContent === 'string' && textContent.trim().length > 0) {
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -102,21 +102,25 @@ export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
|
||||
};
|
||||
|
||||
const extractFinalAssistantText = (turn: Turn): string | undefined => {
|
||||
for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const assistantMsg = turn.assistantMessages[messageIndex];
|
||||
if (!assistantMsg) continue;
|
||||
|
||||
for (const assistantMsg of turn.assistantMessages) {
|
||||
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
|
||||
if (infoFinish !== 'stop') continue;
|
||||
|
||||
if (infoFinish === 'stop') {
|
||||
const textPart = assistantMsg.parts.find(p => p.type === 'text');
|
||||
if (textPart) {
|
||||
const textContent = (textPart as { text?: string | null | undefined }).text ??
|
||||
(textPart as { content?: string | null | undefined }).content;
|
||||
if (typeof textContent === 'string' && textContent.trim().length > 0) {
|
||||
return textContent;
|
||||
}
|
||||
for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
||||
const part = assistantMsg.parts[partIndex];
|
||||
if (!part || part.type !== 'text') continue;
|
||||
|
||||
const textContent = (part as { text?: string | null | undefined }).text ??
|
||||
(part as { content?: string | null | undefined }).content;
|
||||
if (typeof textContent === 'string' && textContent.trim().length > 0) {
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -329,7 +329,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
||||
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
||||
const hasStopFinish = messageFinish === 'stop';
|
||||
|
||||
|
||||
// TTS for message playback
|
||||
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
renderWebSearchOutput,
|
||||
formatInputForDisplay,
|
||||
parseDiffToUnified,
|
||||
parseReadToolOutput,
|
||||
type UnifiedDiffHunk,
|
||||
type SideBySideDiffHunk,
|
||||
type SideBySideDiffLine,
|
||||
@@ -740,33 +741,41 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}
|
||||
|
||||
if (tool === 'read') {
|
||||
const lines = popup.content.split('\n');
|
||||
const parsedReadOutput = parseReadToolOutput(popup.content);
|
||||
|
||||
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('(');
|
||||
let fallbackLineCursor = offset;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{lines.map((line: string, idx: number) => {
|
||||
{parsedReadOutput.lines.map((line, idx: number) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
|
||||
const isInfo = isInfoMessage(line);
|
||||
const shouldAssignFallbackLineNumber =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
|
||||
const lineNumber = offset + idx + 1;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
|
||||
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
|
||||
|
||||
return (
|
||||
<div key={idx} className={`typography-code font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<div key={idx} className={`typography-code font-mono flex ${line.isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
@@ -794,7 +803,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatEditOutput,
|
||||
detectLanguageFromOutput,
|
||||
formatInputForDisplay,
|
||||
parseReadToolOutput,
|
||||
} from '../toolRenderers';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
@@ -808,31 +809,40 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
|
||||
if (hasStringOutput && outputString.trim()) {
|
||||
if (part.tool === 'read') {
|
||||
const formattedOutput = formatEditOutput(outputString, part.tool, metadata);
|
||||
const lines = formattedOutput.split('\n');
|
||||
const parsedReadOutput = parseReadToolOutput(outputString);
|
||||
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('(');
|
||||
const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
|
||||
let fallbackLineCursor = offset;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
|
||||
return renderScrollableBlock(
|
||||
<div className="typography-code 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);
|
||||
{parsedReadOutput.lines.map((line, idx) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
const shouldAssignFallbackLineNumber =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
|
||||
|
||||
return (
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', line.isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)}
|
||||
language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
@@ -855,7 +865,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
@@ -1073,6 +1083,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
|
||||
const justificationText = React.useMemo(() => {
|
||||
if (!showTextJustificationActivity) return null;
|
||||
if (part.tool === 'apply_patch') return null;
|
||||
// Get title or description from state - this is the "yapping" text like "Shows system information"
|
||||
const title = (stateWithData as { title?: string }).title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -1083,7 +1094,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
return inputDesc;
|
||||
}
|
||||
return null;
|
||||
}, [showTextJustificationActivity, stateWithData, input]);
|
||||
}, [showTextJustificationActivity, part.tool, stateWithData, input]);
|
||||
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
|
||||
@@ -44,6 +44,81 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export interface ParsedReadOutputLine {
|
||||
text: string;
|
||||
lineNumber: number | null;
|
||||
isInfo: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedReadToolOutput {
|
||||
type: 'file' | 'directory' | 'unknown';
|
||||
lines: ParsedReadOutputLine[];
|
||||
}
|
||||
|
||||
export const parseReadToolOutput = (output: string): ParsedReadToolOutput => {
|
||||
const typeMatch = output.match(/<type>(file|directory)<\/type>/i);
|
||||
const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type'];
|
||||
|
||||
const contentMatch = output.match(/<content>([\s\S]*?)<\/content>/i);
|
||||
const rawContent = contentMatch?.[1] ?? output;
|
||||
const normalizedContent = rawContent.replace(/\r\n/g, '\n');
|
||||
const rawLines = normalizedContent.split('\n');
|
||||
|
||||
const isTruncationInfoLine = (text: string): boolean => {
|
||||
return /\(\s*File has more lines\..*offset.*\)/i.test(text.trim());
|
||||
};
|
||||
|
||||
const parsedLines = rawLines.map((line): ParsedReadOutputLine => {
|
||||
const trimmed = line.trim();
|
||||
const isInfo = (trimmed.startsWith('(') && trimmed.endsWith(')')) || isTruncationInfoLine(trimmed);
|
||||
|
||||
if (detectedType !== 'directory') {
|
||||
const numberedMatch = line.match(/^(\d+):\s?(.*)$/);
|
||||
if (numberedMatch) {
|
||||
const numberedText = numberedMatch[2];
|
||||
const numberedTrimmed = numberedText.trim();
|
||||
const numberedIsInfo =
|
||||
(numberedTrimmed.startsWith('(') && numberedTrimmed.endsWith(')'))
|
||||
|| isTruncationInfoLine(numberedTrimmed);
|
||||
return {
|
||||
lineNumber: numberedIsInfo ? null : Number(numberedMatch[1]),
|
||||
text: numberedText,
|
||||
isInfo: numberedIsInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lineNumber: null,
|
||||
text: line,
|
||||
isInfo,
|
||||
};
|
||||
});
|
||||
|
||||
const lines = parsedLines.filter((line, index, arr) => {
|
||||
if (line.text.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prev = arr[index - 1];
|
||||
const next = arr[index + 1];
|
||||
const adjacentToInfo = Boolean(prev?.isInfo || next?.isInfo);
|
||||
const hasNumber = line.lineNumber !== null;
|
||||
|
||||
// Drop numbered blank lines wrapped around helper/info rows.
|
||||
if (adjacentToInfo && hasNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
type: detectedType,
|
||||
lines,
|
||||
};
|
||||
};
|
||||
|
||||
export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
|
||||
Reference in New Issue
Block a user