feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
334
dexto/packages/webui/components/tool-renderers/DiffRenderer.tsx
Normal file
334
dexto/packages/webui/components/tool-renderers/DiffRenderer.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* DiffRenderer Component
|
||||
*
|
||||
* Renders unified diff with syntax highlighting.
|
||||
* Shows filename, +N/-M stats, and colored diff lines.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileEdit, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DiffDisplayData } from '@dexto/core';
|
||||
|
||||
interface DiffRendererProps {
|
||||
/** Diff display data from tool result */
|
||||
data: DiffDisplayData;
|
||||
/** Maximum lines before truncation (default: 50) */
|
||||
maxLines?: number;
|
||||
/** Whether to start expanded (default: false) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Diff Parsing (ported from CLI)
|
||||
// =============================================================================
|
||||
|
||||
interface ParsedHunk {
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
lines: ParsedLine[];
|
||||
}
|
||||
|
||||
interface ParsedLine {
|
||||
type: 'context' | 'addition' | 'deletion';
|
||||
content: string;
|
||||
lineNum: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff into structured hunks.
|
||||
*/
|
||||
function parseUnifiedDiff(unified: string): ParsedHunk[] {
|
||||
const lines = unified.split('\n');
|
||||
const hunks: ParsedHunk[] = [];
|
||||
let currentHunk: ParsedHunk | null = null;
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('Index:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
oldLine = parseInt(hunkMatch[1]!, 10);
|
||||
newLine = parseInt(hunkMatch[3]!, 10);
|
||||
currentHunk = {
|
||||
oldStart: oldLine,
|
||||
newStart: newLine,
|
||||
lines: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.slice(1),
|
||||
lineNum: newLine++,
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.slice(1),
|
||||
lineNum: oldLine++,
|
||||
});
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.startsWith(' ') ? line.slice(1) : line,
|
||||
lineNum: newLine,
|
||||
});
|
||||
oldLine++;
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get line number width for consistent alignment.
|
||||
*/
|
||||
function getLineNumWidth(maxLineNum: number): number {
|
||||
return Math.max(3, String(maxLineNum).length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format line number with padding.
|
||||
*/
|
||||
function formatLineNum(num: number, width: number): string {
|
||||
return String(num).padStart(width, ' ');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Line Components
|
||||
// =============================================================================
|
||||
|
||||
interface DiffLineProps {
|
||||
type: 'context' | 'addition' | 'deletion';
|
||||
lineNum: number;
|
||||
lineNumWidth: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single diff line with gutter and content.
|
||||
*/
|
||||
function DiffLine({ type, lineNum, lineNumWidth, content }: DiffLineProps) {
|
||||
const lineNumStr = formatLineNum(lineNum, lineNumWidth);
|
||||
|
||||
const getStyles = () => {
|
||||
switch (type) {
|
||||
case 'deletion':
|
||||
return {
|
||||
bg: 'bg-red-100/50 dark:bg-red-900/20',
|
||||
text: 'text-red-800 dark:text-red-300',
|
||||
symbol: '-',
|
||||
symbolColor: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
case 'addition':
|
||||
return {
|
||||
bg: 'bg-green-100/50 dark:bg-green-900/20',
|
||||
text: 'text-green-800 dark:text-green-300',
|
||||
symbol: '+',
|
||||
symbolColor: 'text-green-600 dark:text-green-400',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: '',
|
||||
text: 'text-foreground/60',
|
||||
symbol: ' ',
|
||||
symbolColor: 'text-transparent',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<div className={cn('flex font-mono text-[11px] leading-5', styles.bg)}>
|
||||
{/* Gutter: line number + symbol */}
|
||||
<div className="flex-shrink-0 select-none">
|
||||
<span className="text-muted-foreground/50 px-1">{lineNumStr}</span>
|
||||
<span className={cn('px-0.5', styles.symbolColor)}>{styles.symbol}</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<pre className={cn('flex-1 px-1 whitespace-pre-wrap break-all', styles.text)}>
|
||||
{content || ' '}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hunk separator.
|
||||
*/
|
||||
function HunkSeparator() {
|
||||
return (
|
||||
<div className="text-muted-foreground text-[10px] py-0.5 px-2 bg-muted/20">
|
||||
<span className="text-muted-foreground/60">···</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract relative path from full path.
|
||||
*/
|
||||
function getRelativePath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 3) return path;
|
||||
return `.../${parts.slice(-3).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders unified diff with syntax highlighting and line numbers.
|
||||
*/
|
||||
export function DiffRenderer({ data, maxLines = 50, defaultExpanded = false }: DiffRendererProps) {
|
||||
const { unified, filename, additions, deletions } = data;
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const hunks = parseUnifiedDiff(unified);
|
||||
|
||||
// Calculate max line number for width
|
||||
let maxLineNum = 1;
|
||||
let totalLines = 0;
|
||||
for (const hunk of hunks) {
|
||||
for (const line of hunk.lines) {
|
||||
maxLineNum = Math.max(maxLineNum, line.lineNum);
|
||||
totalLines++;
|
||||
}
|
||||
}
|
||||
const lineNumWidth = getLineNumWidth(maxLineNum);
|
||||
|
||||
const shouldTruncate = totalLines > maxLines && !showAll;
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(unified);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Header with filename and stats */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FileEdit className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80 truncate" title={filename}>
|
||||
{getRelativePath(filename)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium text-green-600 dark:text-green-400">
|
||||
+{additions}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
||||
-{deletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="pl-5">
|
||||
{!expanded ? (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>
|
||||
Show diff ({totalLines} line{totalLines !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<span>Diff</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-md overflow-hidden border border-border/50">
|
||||
<div className="max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{(() => {
|
||||
let linesRendered = 0;
|
||||
return hunks.map((hunk, hunkIndex) => {
|
||||
if (shouldTruncate && linesRendered >= maxLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
{hunkIndex > 0 && <HunkSeparator />}
|
||||
{hunk.lines.map((line, lineIndex) => {
|
||||
if (
|
||||
shouldTruncate &&
|
||||
linesRendered >= maxLines
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
linesRendered++;
|
||||
return (
|
||||
<DiffLine
|
||||
key={`${hunkIndex}-${lineIndex}`}
|
||||
type={line.type}
|
||||
lineNum={line.lineNum}
|
||||
lineNumWidth={lineNumWidth}
|
||||
content={line.content}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full py-2 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 bg-muted/50 border-t border-border/50"
|
||||
>
|
||||
Show {totalLines - maxLines} more lines...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
dexto/packages/webui/components/tool-renderers/FileRenderer.tsx
Normal file
114
dexto/packages/webui/components/tool-renderers/FileRenderer.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* FileRenderer Component
|
||||
*
|
||||
* Renders file operation metadata (read, write, create, delete).
|
||||
* Compact single-line format with operation badge.
|
||||
*/
|
||||
|
||||
import { FileText, FilePlus, FileX, FileEdit } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileDisplayData } from '@dexto/core';
|
||||
|
||||
interface FileRendererProps {
|
||||
/** File display data from tool result */
|
||||
data: FileDisplayData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation icon and color based on operation type.
|
||||
*/
|
||||
function getOperationInfo(operation: FileDisplayData['operation']) {
|
||||
switch (operation) {
|
||||
case 'read':
|
||||
return {
|
||||
icon: FileText,
|
||||
label: 'Read',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
};
|
||||
case 'write':
|
||||
return {
|
||||
icon: FileEdit,
|
||||
label: 'Updated',
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
};
|
||||
case 'create':
|
||||
return {
|
||||
icon: FilePlus,
|
||||
label: 'Created',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
};
|
||||
case 'delete':
|
||||
return {
|
||||
icon: FileX,
|
||||
label: 'Deleted',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format.
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract relative path (last 2-3 segments) from full path.
|
||||
*/
|
||||
function getRelativePath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 3) return path;
|
||||
return `.../${parts.slice(-3).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders file operation summary as a compact single-line card.
|
||||
*/
|
||||
export function FileRenderer({ data }: FileRendererProps) {
|
||||
const { path, operation, size, lineCount } = data;
|
||||
const opInfo = getOperationInfo(operation);
|
||||
const Icon = opInfo.icon;
|
||||
|
||||
const metadata: string[] = [];
|
||||
if (lineCount !== undefined) {
|
||||
metadata.push(`${lineCount} line${lineCount !== 1 ? 's' : ''}`);
|
||||
}
|
||||
if (size !== undefined) {
|
||||
metadata.push(formatSize(size));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{/* Operation badge */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium',
|
||||
opInfo.bgColor,
|
||||
opInfo.color
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span>{opInfo.label}</span>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<span className="font-mono text-xs text-foreground/80 truncate" title={path}>
|
||||
{getRelativePath(path)}
|
||||
</span>
|
||||
|
||||
{/* Metadata */}
|
||||
{metadata.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
({metadata.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* GenericRenderer Component
|
||||
*
|
||||
* Fallback renderer for unknown tool types or when no display data is available.
|
||||
* Displays raw JSON/text content with syntax highlighting.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
|
||||
// Register JSON language
|
||||
hljs.registerLanguage('json', json);
|
||||
|
||||
interface GenericRendererProps {
|
||||
/** Raw content to display */
|
||||
content: unknown;
|
||||
/** Maximum lines before truncation (default: 20) */
|
||||
maxLines?: number;
|
||||
/** Whether to start expanded (default: false) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent XSS when using dangerouslySetInnerHTML
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders generic tool result content with syntax highlighting.
|
||||
*/
|
||||
export function GenericRenderer({
|
||||
content,
|
||||
maxLines = 20,
|
||||
defaultExpanded = false,
|
||||
}: GenericRendererProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Format content as string
|
||||
const formattedContent =
|
||||
typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
|
||||
const lines = formattedContent.split('\n');
|
||||
const shouldTruncate = lines.length > maxLines && !showAll;
|
||||
const displayContent = shouldTruncate ? lines.slice(0, maxLines).join('\n') : formattedContent;
|
||||
|
||||
// Syntax highlight if it looks like JSON, otherwise escape HTML for safety
|
||||
let highlightedContent: string;
|
||||
try {
|
||||
if (typeof content === 'object' || formattedContent.startsWith('{')) {
|
||||
const result = hljs.highlight(displayContent, { language: 'json' });
|
||||
highlightedContent = result.value;
|
||||
} else {
|
||||
// Not JSON - escape HTML entities for plain text
|
||||
highlightedContent = escapeHtml(displayContent);
|
||||
}
|
||||
} catch {
|
||||
// Highlight failed - escape HTML entities for safety
|
||||
highlightedContent = escapeHtml(displayContent);
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(formattedContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="font-mono">
|
||||
{lines.length} line{lines.length !== 1 ? 's' : ''} of output
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<span>Output</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-md p-2 overflow-x-auto">
|
||||
<pre
|
||||
className={cn(
|
||||
'text-[10px] font-mono text-foreground/80 whitespace-pre-wrap break-all',
|
||||
'max-h-64 overflow-y-auto scrollbar-thin'
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedContent }}
|
||||
/>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-2 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Show {lines.length - maxLines} more lines...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* SearchRenderer Component
|
||||
*
|
||||
* Renders search results (grep, glob) with file:line format.
|
||||
* Shows pattern, match count, and individual results.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Search, ChevronDown, ChevronRight, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SearchDisplayData, SearchMatch } from '@dexto/core';
|
||||
|
||||
interface SearchRendererProps {
|
||||
/** Search display data from tool result */
|
||||
data: SearchDisplayData;
|
||||
/** Maximum matches to show before truncation (default: 5) */
|
||||
maxMatches?: number;
|
||||
/** Whether to start expanded (default: false) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract relative path from full path.
|
||||
*/
|
||||
function getRelativePath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 2) return path;
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single search match result.
|
||||
*/
|
||||
function MatchResult({ match }: { match: SearchMatch }) {
|
||||
const { file, line, content } = match;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-0.5 group">
|
||||
<FileText className="h-3 w-3 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="font-mono text-[11px] text-blue-600 dark:text-blue-400 truncate"
|
||||
title={file}
|
||||
>
|
||||
{getRelativePath(file)}
|
||||
</span>
|
||||
{line > 0 && <span className="text-[10px] text-muted-foreground">:{line}</span>}
|
||||
</div>
|
||||
{content && (
|
||||
<div
|
||||
className="font-mono text-[10px] text-foreground/60 truncate"
|
||||
title={content}
|
||||
>
|
||||
{content.trim()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders search results with collapsible match list.
|
||||
*/
|
||||
export function SearchRenderer({
|
||||
data,
|
||||
maxMatches = 5,
|
||||
defaultExpanded = false,
|
||||
}: SearchRendererProps) {
|
||||
const { pattern, matches, totalMatches, truncated } = data;
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const displayMatches = showAll ? matches : matches.slice(0, maxMatches);
|
||||
const hasMoreMatches = matches.length > maxMatches && !showAll;
|
||||
const wasServerTruncated = truncated;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Header with search info */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs text-foreground/80">
|
||||
<span className="font-medium">{totalMatches}</span>{' '}
|
||||
{totalMatches === 1 ? 'match' : 'matches'} for{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded text-[11px]">{pattern}</code>
|
||||
</span>
|
||||
{wasServerTruncated && (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
(truncated)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results section */}
|
||||
{matches.length > 0 && (
|
||||
<div className="pl-5">
|
||||
{!expanded ? (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>
|
||||
Show {matches.length} result{matches.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<span>Results</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/30 rounded-md p-2',
|
||||
'max-h-64 overflow-y-auto scrollbar-thin'
|
||||
)}
|
||||
>
|
||||
{displayMatches.map((match, index) => (
|
||||
<MatchResult
|
||||
key={`${match.file}:${match.line}:${index}`}
|
||||
match={match}
|
||||
/>
|
||||
))}
|
||||
{hasMoreMatches && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-2 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Show {matches.length - maxMatches} more results...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{matches.length === 0 && (
|
||||
<div className="pl-5 text-xs text-muted-foreground italic">No matches found</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
dexto/packages/webui/components/tool-renderers/ShellRenderer.tsx
Normal file
170
dexto/packages/webui/components/tool-renderers/ShellRenderer.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ShellRenderer Component
|
||||
*
|
||||
* Renders shell command execution results with exit code badge,
|
||||
* duration, and stdout/stderr output.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Terminal, ChevronDown, ChevronRight, Copy, Check, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ShellDisplayData } from '@dexto/core';
|
||||
|
||||
interface ShellRendererProps {
|
||||
/** Shell display data from tool result */
|
||||
data: ShellDisplayData;
|
||||
/** Maximum lines before truncation (default: 10) */
|
||||
maxLines?: number;
|
||||
/** Whether to start expanded (default: based on exit code) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in human-readable format.
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders shell command result with collapsible output.
|
||||
*/
|
||||
export function ShellRenderer({ data, maxLines = 10, defaultExpanded }: ShellRendererProps) {
|
||||
const { command, exitCode, duration, stdout, stderr, isBackground } = data;
|
||||
|
||||
// Expand by default if there was an error
|
||||
const [expanded, setExpanded] = useState(defaultExpanded ?? exitCode !== 0);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const output = stdout || stderr || '';
|
||||
const lines = output.split('\n').filter((line) => line.length > 0);
|
||||
const shouldTruncate = lines.length > maxLines && !showAll;
|
||||
const displayLines = shouldTruncate ? lines.slice(0, maxLines) : lines;
|
||||
|
||||
const isSuccess = exitCode === 0;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API failed - non-secure context or permission denied
|
||||
console.warn('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Header with command and metadata */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Command (truncated) */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Terminal className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<code className="text-xs font-mono text-foreground/80 truncate" title={command}>
|
||||
{command.length > 60 ? `${command.substring(0, 60)}...` : command}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Exit code badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-[10px] font-medium',
|
||||
isSuccess
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{isSuccess ? 'exit 0' : `exit ${exitCode}`}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
|
||||
{/* Background indicator */}
|
||||
{isBackground && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
bg
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output section */}
|
||||
{lines.length > 0 && (
|
||||
<div className="pl-5">
|
||||
{!expanded ? (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>
|
||||
{lines.length} line{lines.length !== 1 ? 's' : ''} of output
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<span>Output</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/30 rounded-md p-2 overflow-x-auto',
|
||||
!isSuccess && 'border-l-2 border-red-500'
|
||||
)}
|
||||
>
|
||||
<pre className="text-[10px] font-mono text-foreground/80 whitespace-pre-wrap break-all max-h-48 overflow-y-auto scrollbar-thin">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-2 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Show {lines.length - maxLines} more lines...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No output indicator */}
|
||||
{lines.length === 0 && (
|
||||
<div className="pl-5 text-xs text-muted-foreground italic">(no output)</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
dexto/packages/webui/components/tool-renderers/index.tsx
Normal file
160
dexto/packages/webui/components/tool-renderers/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Tool Result Renderers
|
||||
*
|
||||
* Dispatch component that renders tool results based on display type.
|
||||
* Uses discriminated union from ToolDisplayData for type-safe rendering.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { ToolDisplayData } from '@dexto/core';
|
||||
import { DiffRenderer } from './DiffRenderer';
|
||||
import { ShellRenderer } from './ShellRenderer';
|
||||
import { SearchRenderer } from './SearchRenderer';
|
||||
import { FileRenderer } from './FileRenderer';
|
||||
import { GenericRenderer } from './GenericRenderer';
|
||||
|
||||
// Re-export individual renderers for direct use
|
||||
export { DiffRenderer } from './DiffRenderer';
|
||||
export { ShellRenderer } from './ShellRenderer';
|
||||
export { SearchRenderer } from './SearchRenderer';
|
||||
export { FileRenderer } from './FileRenderer';
|
||||
export { GenericRenderer } from './GenericRenderer';
|
||||
|
||||
interface ToolResultRendererProps {
|
||||
/** Display data from SanitizedToolResult.meta.display */
|
||||
display?: ToolDisplayData;
|
||||
/** Raw content for fallback rendering */
|
||||
content?: unknown;
|
||||
/** Whether the tool execution was successful */
|
||||
success?: boolean;
|
||||
/** Override default expansion behavior */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the result should be expanded by default.
|
||||
* Smart default: expand errors, collapse successes.
|
||||
*/
|
||||
function shouldExpandByDefault(success: boolean | undefined): boolean {
|
||||
// Expand if there was an error
|
||||
if (success === false) return true;
|
||||
// Otherwise collapse
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the content is an error result (old format with {error: ...}).
|
||||
*/
|
||||
function isLegacyErrorResult(
|
||||
content: unknown
|
||||
): content is { error: string | Record<string, unknown> } {
|
||||
return typeof content === 'object' && content !== null && 'error' in content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the content is a SanitizedToolResult with content array.
|
||||
*/
|
||||
function isSanitizedResult(
|
||||
content: unknown
|
||||
): content is { content: Array<{ type: string; text?: string }> } {
|
||||
return (
|
||||
typeof content === 'object' &&
|
||||
content !== null &&
|
||||
'content' in content &&
|
||||
Array.isArray((content as { content: unknown }).content)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error text from content (handles both formats).
|
||||
*/
|
||||
function extractErrorText(content: unknown): string {
|
||||
// Legacy format: { error: "message" }
|
||||
if (isLegacyErrorResult(content)) {
|
||||
const error = content.error;
|
||||
return typeof error === 'string' ? error : JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
||||
// SanitizedToolResult format: { content: [{ type: 'text', text: '...' }] }
|
||||
if (isSanitizedResult(content)) {
|
||||
const textParts = content.content
|
||||
.filter((part) => part.type === 'text' && part.text)
|
||||
.map((part) => part.text || '')
|
||||
.join('\n');
|
||||
return textParts || 'Unknown error';
|
||||
}
|
||||
|
||||
// Plain string
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return JSON.stringify(content, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error display component for failed tool executions.
|
||||
*/
|
||||
function ErrorRenderer({ errorText }: { errorText: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-red-200 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-red-800 dark:text-red-300 mb-1">
|
||||
Tool execution failed
|
||||
</p>
|
||||
<pre className="text-[11px] font-mono text-red-700 dark:text-red-400 whitespace-pre-wrap break-all max-h-48 overflow-y-auto scrollbar-thin">
|
||||
{errorText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders tool results based on display type.
|
||||
* Falls back to GenericRenderer for unknown types or missing display data.
|
||||
* Shows error renderer for failed tool executions.
|
||||
*/
|
||||
export function ToolResultRenderer({
|
||||
display,
|
||||
content,
|
||||
success,
|
||||
defaultExpanded,
|
||||
}: ToolResultRendererProps) {
|
||||
// Calculate expansion state
|
||||
const shouldExpand = defaultExpanded ?? shouldExpandByDefault(success);
|
||||
|
||||
// If this is an error result, show the error renderer
|
||||
if (success === false) {
|
||||
const errorText = extractErrorText(content);
|
||||
return <ErrorRenderer errorText={errorText} />;
|
||||
}
|
||||
|
||||
// No display data - use generic renderer
|
||||
if (!display) {
|
||||
return <GenericRenderer content={content} defaultExpanded={shouldExpand} />;
|
||||
}
|
||||
|
||||
switch (display.type) {
|
||||
case 'diff':
|
||||
return <DiffRenderer data={display} defaultExpanded={shouldExpand} />;
|
||||
|
||||
case 'shell':
|
||||
return <ShellRenderer data={display} defaultExpanded={shouldExpand} />;
|
||||
|
||||
case 'search':
|
||||
return <SearchRenderer data={display} defaultExpanded={shouldExpand} />;
|
||||
|
||||
case 'file':
|
||||
// File renderer is always visible (no collapse)
|
||||
return <FileRenderer data={display} />;
|
||||
|
||||
case 'generic':
|
||||
default:
|
||||
return <GenericRenderer content={content} defaultExpanded={shouldExpand} />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user