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:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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} />;
}
}