feat(chat): add write/create tool preview in permission cards
This commit is contained in:
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: what
|
id: what
|
||||||
attributes:
|
attributes:
|
||||||
label: What’s wrong? Maybe some steps to reproduce.
|
label: What's wrong? Maybe some steps to reproduce.
|
||||||
description: What happened and what you expected.
|
description: What happened and what you expected.
|
||||||
placeholder: "Expected … but got …"
|
placeholder: "Expected … but got …"
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Added write/create tool preview in permission cards with syntax highlighting
|
||||||
|
|
||||||
## [1.2.5] - 2025-12-19
|
## [1.2.5] - 2025-12-19
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
|
import { parseDiffToUnified } from './message/toolRenderers';
|
||||||
|
|
||||||
|
interface DiffPreviewProps {
|
||||||
|
diff: string;
|
||||||
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, filePath }) => (
|
||||||
|
<div className="typography-code 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 || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{hunk.lines.map((line, lineIdx) => (
|
||||||
|
<div
|
||||||
|
key={lineIdx}
|
||||||
|
className={cn(
|
||||||
|
'typography-code 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(filePath || hunk.file) || 'text'}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLines
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface WritePreviewProps {
|
||||||
|
content: string;
|
||||||
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme, filePath }) => {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const language = getLanguageFromExtension(filePath ?? '') || 'text';
|
||||||
|
const displayPath = filePath?.split('/').pop() || 'New file';
|
||||||
|
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-code 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}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLines
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line || ' '}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiCheckLine, RiCloseLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
@@ -7,6 +7,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||||
|
|
||||||
interface PermissionCardProps {
|
interface PermissionCardProps {
|
||||||
permission: Permission;
|
permission: Permission;
|
||||||
@@ -21,6 +22,10 @@ const getToolIcon = (toolName: string) => {
|
|||||||
return <RiPencilAiLine className={iconClass} />;
|
return <RiPencilAiLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
|
return <RiFileEditLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
||||||
return <RiTerminalBoxLine className={iconClass} />;
|
return <RiTerminalBoxLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
@@ -38,6 +43,9 @@ const getToolDisplayName = (toolName: string): string => {
|
|||||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
return 'edit';
|
return 'edit';
|
||||||
}
|
}
|
||||||
|
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
|
return 'write';
|
||||||
|
}
|
||||||
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
||||||
return 'bash';
|
return 'bash';
|
||||||
}
|
}
|
||||||
@@ -154,75 +162,40 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
|
|
||||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||||
const oldContent = getMeta('old_str') || getMeta('oldString') || getMeta('old_content') || getMeta('before');
|
|
||||||
const newContent = getMeta('new_str') || getMeta('newString') || getMeta('new_content') || getMeta('after');
|
|
||||||
const changes = getMeta('changes') || getMeta('diff');
|
const changes = getMeta('changes') || getMeta('diff');
|
||||||
const replaceAll = getMetaBool('replace_all') || getMetaBool('replaceAll');
|
const replaceAll = getMetaBool('replace_all') || getMetaBool('replaceAll');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filePath && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="typography-meta text-muted-foreground mb-1">File Path:</div>
|
|
||||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
|
||||||
{filePath}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{replaceAll && (
|
{replaceAll && (
|
||||||
<div className="typography-meta text-muted-foreground mb-2">
|
<div className="typography-meta text-muted-foreground mb-2">
|
||||||
<span className="font-semibold">⚠️ Replace All Occurrences</span>
|
<span className="font-semibold">⚠️ Replace All Occurrences</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{changes ? (
|
{changes && (
|
||||||
<div>
|
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
|
||||||
<div className="typography-meta text-muted-foreground mb-1">Changes:</div>
|
<DiffPreview diff={changes} syntaxTheme={syntaxTheme} filePath={filePath} />
|
||||||
<ScrollableOverlay outerClassName="max-h-64" className="overflow-x-auto p-0">
|
</ScrollableOverlay>
|
||||||
<SyntaxHighlighter
|
|
||||||
language="diff"
|
|
||||||
style={syntaxTheme}
|
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
wrapLongLines={false}
|
|
||||||
>
|
|
||||||
{changes}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</ScrollableOverlay>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{oldContent && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="typography-meta text-muted-foreground mb-1">Remove:</div>
|
|
||||||
<ScrollableOverlay outerClassName="max-h-32 border border-red-500/20 rounded bg-red-500/5 p-2" className="p-0">
|
|
||||||
<pre className="typography-meta font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
|
||||||
{oldContent.length > 500 ? oldContent.substring(0, 500) + '...' : oldContent}
|
|
||||||
</pre>
|
|
||||||
</ScrollableOverlay>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{newContent && (
|
|
||||||
<div>
|
|
||||||
<div className="typography-meta text-muted-foreground mb-1">Replace with:</div>
|
|
||||||
<ScrollableOverlay outerClassName="max-h-32 border border-green-500/20 rounded bg-green-500/5 p-2" className="p-0">
|
|
||||||
<pre className="typography-meta font-mono text-green-600 dark:text-green-400 whitespace-pre-wrap break-all">
|
|
||||||
{newContent.length > 500 ? newContent.substring(0, 500) + '...' : newContent}
|
|
||||||
</pre>
|
|
||||||
</ScrollableOverlay>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
|
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||||
|
const content = getMeta('content') || getMeta('text') || getMeta('data');
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
return (
|
||||||
|
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
|
||||||
|
<WritePreview content={content} syntaxTheme={syntaxTheme} filePath={filePath} />
|
||||||
|
</ScrollableOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||||
const url = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
const url = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
||||||
const method = getMeta('method') || 'GET';
|
const method = getMeta('method') || 'GET';
|
||||||
@@ -369,7 +342,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
shouldHighlight = true;
|
shouldHighlight = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool' || tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||||
shouldHighlight = false;
|
shouldHighlight = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user