feat(cli-stream-monitor): add JSON card components and refactor tab UI

Add new components for CLI stream monitoring with JSON visualization:
- ExecutionTab: simplified tab with status indicator and active styling
- JsonCard: collapsible card for JSON data with type-based styling (6 types)
- JsonField: recursive JSON field renderer with color-coded values
- OutputLine: auto-detects JSON and renders appropriate component
- jsonDetector: smart JSON detection supporting 5 formats

Refactor CliStreamMonitorLegacy to use new components:
- Replace inline tab rendering with ExecutionTab component
- Replace inline output rendering with OutputLine component
- Add Badge import for active count display
- Fix type safety with proper id propagation

Component features:
- Type-specific styling for tool_call, metadata, system, stdout, stderr, thought
- Collapsible content (show 3 fields by default, expandable)
- Copy button and raw JSON view toggle
- Timestamp display
- Auto-detection of JSON in output lines

Fixes:
- Missing jsonDetector.ts file
- Type mismatch between OutputLine (6 types) and JsonCard (4 types)
- Unused isActive prop in ExecutionTab
This commit is contained in:
catlog22
2026-02-01 11:14:33 +08:00
parent b66d20f5a6
commit cf401d00e1
10 changed files with 633 additions and 83 deletions

View File

@@ -0,0 +1,67 @@
// ========================================
// ExecutionTab Component
// ========================================
// Tab component for displaying CLI execution status
import { TabsTrigger } from '@/components/ui/Tabs';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliExecutionState } from '@/stores/cliStreamStore';
export interface ExecutionTabProps {
execution: CliExecutionState & { id: string };
isActive: boolean;
onClick: () => void;
onClose: (e: React.MouseEvent) => void;
}
export function ExecutionTab({ execution, isActive, onClick, onClose }: ExecutionTabProps) {
// Simplify tool name (e.g., gemini-2.5-pro -> gemini)
const toolNameShort = execution.tool.split('-')[0];
// Status color mapping
const statusColor = {
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500',
error: 'bg-red-500',
}[execution.status];
return (
<TabsTrigger
value={execution.id}
onClick={onClick}
className={cn(
'gap-2 text-xs px-3 py-1.5',
isActive
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 hover:bg-muted/70',
'transition-colors'
)}
>
{/* Status indicator dot */}
<span className={cn('w-2 h-2 rounded-full shrink-0', statusColor)} />
{/* Simplified tool name */}
<span className="font-medium">{toolNameShort}</span>
{/* Execution mode */}
<span className="opacity-70">{execution.mode}</span>
{/* Line count statistics */}
<span className="text-[10px] opacity-50 tabular-nums">
{execution.output.length} lines
</span>
{/* Close button */}
<button
onClick={onClose}
className="ml-1 p-0.5 rounded hover:bg-destructive/20 transition-colors"
aria-label="Close execution tab"
>
<X className="h-3 w-3" />
</button>
</TabsTrigger>
);
}
export default ExecutionTab;

View File

@@ -0,0 +1,186 @@
// ========================================
// JsonCard Component
// ========================================
// Collapsible card component for displaying JSON data with type-based styling
import { useState } from 'react';
import {
Wrench,
Settings,
Info,
Code,
Copy,
ChevronRight,
AlertTriangle,
Brain,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import { JsonField } from './JsonField';
// ========== Types ==========
export interface JsonCardProps {
/** JSON data to display */
data: Record<string, unknown>;
/** Type of the card (affects styling and icon) */
type: 'tool_call' | 'metadata' | 'system' | 'stdout' | 'stderr' | 'thought';
/** Timestamp for the data */
timestamp?: number;
/** Callback when copy button is clicked */
onCopy?: () => void;
}
// ========== Type Configuration ==========
type TypeConfig = {
icon: typeof Wrench;
label: string;
color: string;
bg: string;
};
const TYPE_CONFIGS: Record<string, TypeConfig> = {
tool_call: {
icon: Wrench,
label: 'Tool Call',
color: 'text-green-400',
bg: 'bg-green-950/30 border-green-900/50',
},
metadata: {
icon: Info,
label: 'Metadata',
color: 'text-yellow-400',
bg: 'bg-yellow-950/30 border-yellow-900/50',
},
system: {
icon: Settings,
label: 'System',
color: 'text-blue-400',
bg: 'bg-blue-950/30 border-blue-900/50',
},
stdout: {
icon: Code,
label: 'Data',
color: 'text-cyan-400',
bg: 'bg-cyan-950/30 border-cyan-900/50',
},
stderr: {
icon: AlertTriangle,
label: 'Error',
color: 'text-red-400',
bg: 'bg-red-950/30 border-red-900/50',
},
thought: {
icon: Brain,
label: 'Thought',
color: 'text-purple-400',
bg: 'bg-purple-950/30 border-purple-900/50',
},
};
// ========== Component ==========
export function JsonCard({
data,
type,
timestamp,
onCopy,
}: JsonCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const entries = Object.entries(data);
const visibleCount = isExpanded ? entries.length : 3;
const hasMore = entries.length > 3;
const config = TYPE_CONFIGS[type];
const Icon = config.icon;
return (
<div className={cn('border rounded-lg overflow-hidden my-2', config.bg)}>
{/* Header */}
<div
className={cn(
'flex items-center justify-between px-3 py-2 cursor-pointer',
'hover:bg-black/5 dark:hover:bg-white/5 transition-colors'
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-4 w-4', config.color)} />
<span className="text-sm font-medium">{config.label}</span>
<Badge variant="secondary" className="text-xs h-5">
{entries.length}
</Badge>
</div>
<div className="flex items-center gap-2">
{timestamp && (
<span className="text-xs text-muted-foreground font-mono">
{new Date(timestamp).toLocaleTimeString()}
</span>
)}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onCopy?.();
}}
>
<Copy className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setShowRaw(!showRaw);
}}
>
<Code className="h-3 w-3" />
</Button>
<ChevronRight
className={cn(
'h-4 w-4 transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</div>
{/* Content */}
{showRaw ? (
<pre className="p-3 text-xs bg-black/20 overflow-x-auto max-h-60">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
) : (
<div className="divide-y divide-border/30">
{entries.slice(0, visibleCount).map(([key, value]) => (
<JsonField key={key} fieldName={key} value={value} />
))}
{hasMore && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="w-full px-3 py-2 text-xs text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-left"
>
{isExpanded
? '▲ Show less'
: `▼ Show ${entries.length - 3} more fields`}
</button>
)}
</div>
)}
</div>
);
}
export default JsonCard;

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
export interface JsonFieldProps {
fieldName: string;
value: unknown;
}
export function JsonField({ fieldName, value }: JsonFieldProps) {
const [isExpanded, setIsExpanded] = useState(false);
const isObject = value !== null && typeof value === 'object';
const isNested = isObject && (Array.isArray(value) || Object.keys(value).length > 0);
const renderPrimitiveValue = (val: unknown): React.ReactNode => {
if (val === null) return <span className="text-muted-foreground italic">null</span>;
if (typeof val === 'boolean') return <span className="text-purple-400 font-medium">{String(val)}</span>;
if (typeof val === 'number') return <span className="text-orange-400 font-mono">{String(val)}</span>;
if (typeof val === 'string') {
// Check if it's a JSON string
const trimmed = val.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return <span className="text-green-400">"{trimmed.substring(0, 30)}..."</span>;
}
return <span className="text-green-400">"{val}"</span>;
}
return String(val);
};
return (
<div className={cn(
'flex items-start gap-2 px-3 py-2 hover:bg-black/5 dark:hover:bg-white/5 transition-colors',
'text-sm'
)}>
{/* Field name */}
<span className="shrink-0 font-mono text-cyan-400 min-w-[100px]">
{fieldName}
</span>
{/* Separator */}
<span className="shrink-0 text-muted-foreground">:</span>
{/* Value */}
<div className="flex-1 min-w-0">
{isNested ? (
<details
open={isExpanded}
onToggle={(e) => setIsExpanded(e.currentTarget.open)}
className="group"
>
<summary className="cursor-pointer list-none flex items-center gap-1 hover:text-foreground">
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
{isExpanded ? '▼' : '▶'}
</span>
{Array.isArray(value) ? (
<span className="text-blue-400">Array[{value.length}]</span>
) : (
<span className="text-yellow-400">Object{'{'}{Object.keys(value).length}{'}'}</span>
)}
</summary>
{isExpanded && (
<div className="ml-4 mt-2 space-y-1">
{Array.isArray(value)
? value.map((item, i) => (
<div key={i} className="pl-2 border-l border-border/30">
{typeof item === 'object' && item !== null ? (
<JsonField fieldName={`[${i}]`} value={item} />
) : (
renderPrimitiveValue(item)
)}
</div>
))
: Object.entries(value as Record<string, unknown>).map(([k, v]) => (
<JsonField key={k} fieldName={k} value={v} />
))
}
</div>
)}
</details>
) : (
<div className="break-all">{renderPrimitiveValue(value)}</div>
)}
</div>
</div>
);
}
export default JsonField;

View File

@@ -0,0 +1,107 @@
// ========================================
// OutputLine Component
// ========================================
// Renders a single output line with JSON auto-detection
import { useMemo } from 'react';
import { Brain, Settings, AlertCircle, Info, MessageCircle, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
import { JsonCard } from './JsonCard';
import { detectJsonInLine } from '../utils/jsonDetector';
// ========== Types ==========
export interface OutputLineProps {
line: {
type: 'stdout' | 'stderr' | 'metadata' | 'thought' | 'system' | 'tool_call';
content: string;
timestamp: number;
};
onCopy?: (content: string) => void;
}
// ========== Helper Functions ==========
/**
* Get the icon component for a given output line type
*/
function getOutputLineIcon(type: OutputLineProps['line']['type']) {
switch (type) {
case 'thought':
return <Brain className="h-3 w-3" />;
case 'system':
return <Settings className="h-3 w-3" />;
case 'stderr':
return <AlertCircle className="h-3 w-3" />;
case 'metadata':
return <Info className="h-3 w-3" />;
case 'tool_call':
return <Wrench className="h-3 w-3" />;
case 'stdout':
default:
return <MessageCircle className="h-3 w-3" />;
}
}
/**
* Get the CSS class name for a given output line type
* Reuses the existing implementation from LogBlock utils
*/
function getOutputLineClass(type: OutputLineProps['line']['type']): string {
switch (type) {
case 'thought':
return 'text-purple-400';
case 'system':
return 'text-blue-400';
case 'stderr':
return 'text-red-400';
case 'metadata':
return 'text-yellow-400';
case 'tool_call':
return 'text-green-400';
case 'stdout':
default:
return 'text-foreground';
}
}
// ========== Component ==========
/**
* OutputLine - Renders a single CLI output line
*
* Features:
* - Auto-detects JSON content and renders with JsonCard
* - Shows appropriate icon based on line type
* - Applies color styling based on line type
* - Supports copy functionality
*/
export function OutputLine({ line, onCopy }: OutputLineProps) {
// Memoize JSON detection to avoid re-parsing on every render
const jsonDetection = useMemo(() => detectJsonInLine(line.content), [line.content]);
return (
<div className={cn('flex gap-2 text-xs', getOutputLineClass(line.type))}>
{/* Icon indicator */}
<span className="text-muted-foreground shrink-0 mt-0.5">
{getOutputLineIcon(line.type)}
</span>
{/* Content area */}
<div className="flex-1 min-w-0">
{jsonDetection.isJson && jsonDetection.parsed ? (
<JsonCard
data={jsonDetection.parsed}
type={line.type as 'tool_call' | 'metadata' | 'system' | 'stdout'}
timestamp={line.timestamp}
onCopy={() => onCopy?.(line.content)}
/>
) : (
<span className="break-all whitespace-pre-wrap">{line.content}</span>
)}
</div>
</div>
);
}
export default OutputLine;

View File

@@ -0,0 +1,19 @@
// ========================================
// CliStreamMonitor Components Index
// ========================================
// Export barrel for CliStreamMonitor components
// Tab component
export { ExecutionTab } from './ExecutionTab';
export type { ExecutionTabProps } from './ExecutionTab';
// JSON display components
export { JsonCard } from './JsonCard';
export type { JsonCardProps } from './JsonCard';
export { JsonField } from './JsonField';
export type { JsonFieldProps } from './JsonField';
// Output components
export { OutputLine } from './OutputLine';
export type { OutputLineProps } from './OutputLine';

View File

@@ -1,13 +1,15 @@
// ========================================
// CliStreamMonitor Component Exports
// ========================================
// New layout exports for the redesigned CLI Stream Monitor
// Main component (new layout)
// Main components
export { CliStreamMonitorNew as CliStreamMonitor } from './CliStreamMonitorNew';
export type { CliStreamMonitorNewProps as CliStreamMonitorProps } from './CliStreamMonitorNew';
// Layout components
export { default as CliStreamMonitorLegacy } from '../CliStreamMonitorLegacy';
export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from '../CliStreamMonitorLegacy';
// Layout components (new design)
export { MonitorHeader } from './MonitorHeader';
export type { MonitorHeaderProps } from './MonitorHeader';
@@ -17,7 +19,7 @@ export type { MonitorToolbarProps, FilterType, ViewMode } from './MonitorToolbar
export { MonitorBody } from './MonitorBody';
export type { MonitorBodyProps, MonitorBodyRef } from './MonitorBody';
// Message type components
// Message type components (new design)
export {
SystemMessage,
UserMessage,
@@ -31,6 +33,23 @@ export type {
ErrorMessageProps,
} from './messages';
// Message renderer
// Message renderer (new design)
export { MessageRenderer } from './MessageRenderer';
export type { MessageRendererProps } from './MessageRenderer';
// Utility components for Tab + JSON Cards (v3 design)
export { ExecutionTab } from './components/ExecutionTab';
export type { ExecutionTabProps } from './components/ExecutionTab';
export { JsonCard } from './components/JsonCard';
export type { JsonCardProps } from './components/JsonCard';
export { JsonField } from './components/JsonField';
export type { JsonFieldProps } from './components/JsonField';
export { OutputLine } from './components/OutputLine';
export type { OutputLineProps } from './components/OutputLine';
// Utilities
export { detectJsonInLine } from './utils/jsonDetector';
export type { JsonDetectionResult } from './utils/jsonDetector';

View File

@@ -0,0 +1,8 @@
// ========================================
// CliStreamMonitor Utilities Index
// ========================================
// Export barrel for CliStreamMonitor utilities
// JSON detection utility
export { detectJsonInLine } from './jsonDetector';
export type { JsonDetectionResult } from './jsonDetector';

View File

@@ -0,0 +1,104 @@
// ========================================
// JSON Detection Utility
// ========================================
// Smart JSON detection for CLI output lines
/**
* Result of JSON detection
*/
export interface JsonDetectionResult {
isJson: boolean;
parsed?: Record<string, unknown>;
error?: string;
}
/**
* Detect if a line contains JSON data
* Supports multiple formats:
* - Direct JSON: {...} or [...]
* - Tool Call: [Tool] toolName({...})
* - Tool Result: [Tool Result] status: {...}
* - Embedded JSON: trailing JSON object
* - Code block JSON: ```json ... ```
*/
export function detectJsonInLine(content: string): JsonDetectionResult {
const trimmed = content.trim();
// 1. Direct JSON object or array
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
return { isJson: true, parsed: parsed as Record<string, unknown> };
} catch {
// Continue to other patterns
}
}
// 2. Tool Call format: [Tool] toolName({...})
const toolCallMatch = trimmed.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
if (toolCallMatch) {
const [, toolName, paramsStr] = toolCallMatch;
let parameters: unknown;
try {
parameters = paramsStr ? JSON.parse(paramsStr) : {};
} catch {
parameters = paramsStr || null;
}
return {
isJson: true,
parsed: {
action: 'invoke',
toolName,
parameters,
} as Record<string, unknown>,
};
}
// 3. Tool Result format: [Tool Result] status: output
const toolResultMatch = trimmed.match(/^\[Tool Result\]\s+(.+?)\s*:\s*(.+)$/);
if (toolResultMatch) {
const [, status, outputStr] = toolResultMatch;
let output: unknown;
try {
output = outputStr.trim().startsWith('{') ? JSON.parse(outputStr) : outputStr;
} catch {
output = outputStr;
}
return {
isJson: true,
parsed: {
action: 'result',
status,
output,
} as Record<string, unknown>,
};
}
// 4. Embedded JSON at end of line
const embeddedJsonMatch = trimmed.match(/\{.*\}$/);
if (embeddedJsonMatch) {
try {
const parsed = JSON.parse(embeddedJsonMatch[0]);
return { isJson: true, parsed: parsed as Record<string, unknown> };
} catch {
// Not valid JSON
}
}
// 5. Code block JSON
const codeBlockMatch = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
if (codeBlockMatch) {
try {
const parsed = JSON.parse(codeBlockMatch[1]);
return { isJson: true, parsed: parsed as Record<string, unknown> };
} catch {
return { isJson: false, error: 'Invalid JSON in code block' };
}
}
return { isJson: false };
}

View File

@@ -9,28 +9,25 @@ import {
X,
Terminal,
Loader2,
AlertCircle,
Clock,
RefreshCw,
Search,
XCircle,
ArrowDownToLine,
Brain,
Settings,
Info,
MessageCircle,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { LogBlockList, getOutputLineClass } from '@/components/shared/LogBlock';
import { Badge } from '@/components/ui/Badge';
import { LogBlockList } from '@/components/shared/LogBlock';
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
import { useNotificationStore, selectWsLastMessage } from '@/stores';
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
// New components for Tab + JSON Cards
import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab';
import { OutputLine } from './CliStreamMonitor/components/OutputLine';
// ========== Types for CLI WebSocket Messages ==========
interface CliStreamStartedPayload {
@@ -77,25 +74,6 @@ function formatDuration(ms: number): string {
return `${hours}h ${remainingMinutes}m`;
}
// Local function for icon rendering (uses JSX, must stay in .tsx file)
function getOutputLineIcon(type: CliOutputLine['type']) {
switch (type) {
case 'thought':
return <Brain className="h-3 w-3" />;
case 'system':
return <Settings className="h-3 w-3" />;
case 'stderr':
return <AlertCircle className="h-3 w-3" />;
case 'metadata':
return <Info className="h-3 w-3" />;
case 'tool_call':
return <Wrench className="h-3 w-3" />;
case 'stdout':
default:
return <MessageCircle className="h-3 w-3" />;
}
}
// ========== Component ==========
export interface CliStreamMonitorProps {
@@ -343,43 +321,18 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
className="w-full"
>
<TabsList className="w-full h-auto flex-wrap gap-1 bg-secondary/50 p-1">
{sortedExecutionIds.map((id) => {
const exec = executions[id];
return (
<TabsTrigger
key={id}
value={id}
className={cn(
'gap-1.5 text-xs px-2 py-1',
exec.status === 'running' && 'bg-primary text-primary-foreground'
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', {
'bg-green-500 animate-pulse': exec.status === 'running',
'bg-blue-500': exec.status === 'completed',
'bg-red-500': exec.status === 'error'
})} />
<span className="font-medium">{exec.tool}</span>
<span className="text-muted-foreground">{exec.mode}</span>
{exec.recovered && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
Recovered
</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1 hover:bg-destructive hover:text-destructive-foreground"
onClick={(e) => {
e.stopPropagation();
removeExecution(id);
}}
>
<XCircle className="h-3 w-3" />
</Button>
</TabsTrigger>
);
})}
{sortedExecutionIds.map((id) => (
<ExecutionTab
key={id}
execution={{ ...executions[id], id }}
isActive={currentExecutionId === id}
onClick={() => setCurrentExecution(id)}
onClose={(e) => {
e.stopPropagation();
removeExecution(id);
}}
/>
))}
</TabsList>
{/* Output Panel */}
@@ -459,12 +412,11 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
) : (
<div className="space-y-1">
{filteredOutput.map((line, index) => (
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
<span className="text-muted-foreground shrink-0">
{getOutputLineIcon(line.type)}
</span>
<span className="break-all">{line.content}</span>
</div>
<OutputLine
key={`${line.timestamp}-${index}`}
line={line}
onCopy={(content) => navigator.clipboard.writeText(content)}
/>
))}
<div ref={logsEndRef} />
</div>

View File

@@ -60,18 +60,18 @@ export type { FlowchartProps } from './Flowchart';
export { CliStreamPanel } from './CliStreamPanel';
export type { CliStreamPanelProps } from './CliStreamPanel';
// New CliStreamMonitor with message-based layout
export { CliStreamMonitor } from './CliStreamMonitor/index';
export type { CliStreamMonitorProps } from './CliStreamMonitor/index';
// CliStreamMonitor (updated with Tab + JSON Cards - v3 design)
export { default as CliStreamMonitor } from './CliStreamMonitorLegacy';
export type { CliStreamMonitorProps } from './CliStreamMonitorLegacy';
// Legacy CliStreamMonitor (old layout)
export { default as CliStreamMonitorLegacy } from './CliStreamMonitorLegacy';
export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from './CliStreamMonitorLegacy';
// Alternative: New message-based layout (CliStreamMonitorNew)
export { CliStreamMonitor as CliStreamMonitorNew } from './CliStreamMonitor/index';
export type { CliStreamMonitorProps as CliStreamMonitorNewProps } from './CliStreamMonitor/index';
export { StreamingOutput } from './StreamingOutput';
export type { StreamingOutputProps } from './StreamingOutput';
// CliStreamMonitor sub-components
// CliStreamMonitor sub-components (new design)
export { MonitorHeader } from './CliStreamMonitor/index';
export type { MonitorHeaderProps } from './CliStreamMonitor/index';