feat: add CliStreamMonitor and related components for CLI output streaming

- Implemented CliStreamMonitor component for real-time CLI output monitoring with multi-execution support.
- Created JsonFormatter component for displaying JSON content in various formats (text, card, inline).
- Added utility functions for JSON detection and formatting in jsonUtils.ts.
- Introduced LogBlock utility functions for styling CLI output lines.
- Developed a new Collapsible component for better UI interactions.
- Created IssueHubPage for managing issues, queue, and discovery with tab navigation.
This commit is contained in:
catlog22
2026-01-31 23:12:39 +08:00
parent 2f10305945
commit a2206df50f
43 changed files with 5843 additions and 466 deletions

View File

@@ -0,0 +1,353 @@
// ========================================
// JsonFormatter Component
// ========================================
// Displays JSON content in formatted text or card view
import { useState, useCallback, useMemo } from 'react';
import { ChevronDown, ChevronRight, Copy, Check, Braces } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import {
detectJsonContent,
formatJson,
getJsonSummary,
getJsonValueTypeColor,
type JsonDisplayMode,
} from './jsonUtils';
// ========== Types ==========
export interface JsonFormatterProps {
/** Content to format */
content: string;
/** Display mode */
displayMode?: JsonDisplayMode;
/** CSS className */
className?: string;
/** Maximum lines for text mode (default: 20) */
maxLines?: number;
/** Whether to show type labels in card mode (default: true) */
showTypeLabels?: boolean;
}
// ========== Helper Components ==========
/**
* Copy button with feedback
*/
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}, [text]);
return (
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
{copied ? (
<>
<Check className="h-3 w-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
Copy
</>
)}
</Button>
);
}
/**
* JSON value renderer with syntax highlighting
*/
function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) {
const indent = ' '.repeat(depth);
const colorClass = getJsonValueTypeColor(value);
if (value === null) {
return <span className={colorClass}>null</span>;
}
if (typeof value === 'boolean') {
return <span className={colorClass}>{String(value)}</span>;
}
if (typeof value === 'number') {
return <span className={colorClass}>{String(value)}</span>;
}
if (typeof value === 'string') {
return <span className={colorClass}>"{value}"</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-blue-400">[]</span>;
}
return (
<div className="text-blue-400">
<span>[</span>
<div className="pl-4">
{value.map((item, index) => (
<div key={index} className="hover:bg-muted/50 rounded px-1">
<JsonValue value={item} depth={depth + 1} />
{index < value.length - 1 && <span className="text-foreground">,</span>}
</div>
))}
</div>
<span>{indent}]</span>
</div>
);
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return <span className="text-yellow-400">{`{}`}</span>;
}
return (
<div className="text-yellow-400">
<span>{`{`}</span>
<div className="pl-4">
{entries.map(([key, val], index) => (
<div key={key} className="hover:bg-muted/50 rounded px-1">
<span className="text-cyan-400">"{key}"</span>
<span className="text-foreground">: </span>
<JsonValue value={val} depth={depth + 1} />
{index < entries.length - 1 && <span className="text-foreground">,</span>}
</div>
))}
</div>
<span>{indent}{`}`}</span>
</div>
);
}
return <span>{String(value)}</span>;
}
/**
* Compact JSON view for inline display
*/
function JsonCompact({ data }: { data: unknown }) {
return (
<code className="text-xs font-mono">
<JsonValue value={data} />
</code>
);
}
/**
* Card view for structured JSON display
*/
function JsonCard({ data, showTypeLabels = true }: { data: unknown; showTypeLabels?: boolean }) {
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const toggleKey = useCallback((key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
// Primitive or array - use inline view
return (
<div className="p-3 bg-muted/30 rounded border border-border">
<JsonCompact data={data} />
</div>
);
}
const entries = Object.entries(data as Record<string, unknown>);
return (
<div className="border border-border rounded-lg overflow-hidden bg-card">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b border-border">
<Braces className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">JSON Data</span>
<span className="text-xs text-muted-foreground">
({entries.length} {entries.length === 1 ? 'property' : 'properties'})
</span>
</div>
{/* Properties */}
<div className="divide-y divide-border">
{entries.map(([key, value]) => {
const isObject = value !== null && typeof value === 'object';
const isExpanded = expandedKeys.has(key);
const isArray = Array.isArray(value);
const summary = getJsonSummary(value);
return (
<div key={key} className="group">
<div
className={cn(
'flex items-start gap-2 px-3 py-2 hover:bg-muted/30 transition-colors',
isObject && 'cursor-pointer'
)}
onClick={() => isObject && toggleKey(key)}
>
{/* Expand/collapse icon */}
{isObject && (
<div className="shrink-0 mt-0.5">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
)}
{/* Key */}
<span className="font-mono text-sm text-cyan-400 shrink-0">"{key}"</span>
<span className="text-muted-foreground shrink-0">:</span>
{/* Value summary or full value */}
<div className={cn('flex-1 min-w-0', getJsonValueTypeColor(value))}>
{showTypeLabels && (
<span className="text-xs text-muted-foreground mr-1">
{isArray ? 'array' : isObject ? 'object' : typeof value}
</span>
)}
{!isObject ? (
<span className="text-sm font-mono break-all">{summary}</span>
) : (
<span className="text-sm">{summary}</span>
)}
</div>
</div>
{/* Expanded nested object */}
{isObject && isExpanded && (
<div className="pl-8 pr-3 pb-2">
<div className="p-3 bg-muted/30 rounded border border-border">
<JsonCard data={value} showTypeLabels={showTypeLabels} />
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/**
* Text view for formatted JSON
*/
function JsonText({ data, maxLines = 20 }: { data: unknown; maxLines?: number }) {
const formatted = useMemo(() => formatJson(data), [data]);
const lines = formatted.split('\n');
const showTruncated = maxLines && lines.length > maxLines;
const displayLines = showTruncated ? lines.slice(0, maxLines) : lines;
return (
<div className="relative group">
<pre className="text-xs font-mono bg-muted/30 p-3 rounded border border-border overflow-x-auto">
<code className="text-foreground">
{displayLines.map((line, i) => (
<div key={i} className="hover:bg-muted/50 px-1 -mx-1">
{line}
</div>
))}
{showTruncated && (
<div className="text-muted-foreground italic">
// ... {lines.length - maxLines} more lines
</div>
)}
</code>
</pre>
{/* Copy button */}
<div className="absolute top-2 right-2">
<CopyButton text={formatted} />
</div>
</div>
);
}
// ========== Main Component ==========
/**
* JsonFormatter Component
*
* Displays JSON content in various formats:
* - `text`: Formatted JSON text with syntax highlighting
* - `card`: Structured card view with collapsible properties
* - `inline`: Compact inline display
*
* Auto-detects JSON from mixed content and validates it.
*/
export function JsonFormatter({
content,
displayMode = 'text',
className,
maxLines = 20,
showTypeLabels = true,
}: JsonFormatterProps) {
// Detect JSON content
const detection = useMemo(() => detectJsonContent(content), [content]);
// Not JSON or invalid - show as plain text
if (!detection.isJson) {
return (
<div className={cn('text-xs font-mono text-foreground', className)}>
{content}
</div>
);
}
// Valid JSON - render based on display mode
switch (displayMode) {
case 'card':
return (
<div className={className}>
<JsonCard data={detection.parsed} showTypeLabels={showTypeLabels} />
</div>
);
case 'inline':
return (
<div className={cn('inline-flex items-center gap-1 px-2 py-1 bg-muted/50 rounded border border-border', className)}>
<Braces className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="text-xs font-mono">
<JsonCompact data={detection.parsed} />
</span>
</div>
);
case 'text':
default:
return (
<div className={className}>
<JsonText data={detection.parsed} maxLines={maxLines} />
</div>
);
}
}
export default JsonFormatter;

View File

@@ -22,8 +22,9 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import type { LogBlockProps, LogLine } from './types';
import { getOutputLineClass } from './utils';
// Re-use output line styling helpers from CliStreamMonitor
// Local function for icon rendering (uses JSX, must stay in .tsx file)
function getOutputLineIcon(type: LogLine['type']) {
switch (type) {
case 'thought':
@@ -42,24 +43,6 @@ function getOutputLineIcon(type: LogLine['type']) {
}
}
function getOutputLineClass(type: LogLine['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';
}
}
function getBlockBorderClass(status: LogBlockProps['block']['status']): string {
switch (status) {
case 'running':
@@ -247,13 +230,22 @@ export const LogBlock = memo(function LogBlock({
);
}, (prevProps, nextProps) => {
// Custom comparison for performance
// Compare all relevant block fields to detect changes
const prevBlock = prevProps.block;
const nextBlock = nextProps.block;
return (
prevProps.block.id === nextProps.block.id &&
prevProps.block.status === nextProps.block.status &&
prevProps.block.lineCount === nextProps.block.lineCount &&
prevProps.block.duration === nextProps.block.duration &&
prevProps.isExpanded === nextProps.isExpanded &&
prevProps.className === nextProps.className
prevProps.className === nextProps.className &&
prevBlock.id === nextBlock.id &&
prevBlock.status === nextBlock.status &&
prevBlock.title === nextBlock.title &&
prevBlock.toolName === nextBlock.toolName &&
prevBlock.lineCount === nextBlock.lineCount &&
prevBlock.duration === nextBlock.duration
// Note: We don't compare block.lines deeply for performance reasons.
// The store's getBlocks method returns cached arrays, so if lines change
// significantly, a new block object will be created and the id will change.
);
});

View File

@@ -3,208 +3,9 @@
// ========================================
// Container component for displaying grouped CLI output blocks
import React, { useState, useMemo, useCallback } from 'react';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { useState, useCallback, useMemo } from 'react';
import { useCliStreamStore, type LogBlockData } from '@/stores/cliStreamStore';
import { LogBlock } from './LogBlock';
import type { LogBlockData, LogLine } from './types';
import type { CliOutputLine } from '@/stores/cliStreamStore';
/**
* Parse tool call metadata from content
* Expected format: "[Tool] toolName(args)"
*/
function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined {
const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
if (toolCallMatch) {
return {
toolName: toolCallMatch[1],
args: toolCallMatch[2] || '',
};
}
return undefined;
}
/**
* Generate block title based on type and content
*/
function generateBlockTitle(lineType: string, content: string): string {
switch (lineType) {
case 'tool_call':
const metadata = parseToolCallMetadata(content);
if (metadata) {
return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName;
}
return 'Tool Call';
case 'thought':
return 'Thought';
case 'system':
return 'System';
case 'stderr':
return 'Error Output';
case 'stdout':
return 'Output';
case 'metadata':
return 'Metadata';
default:
return 'Log';
}
}
/**
* Get block type for a line
*/
function getBlockType(lineType: string): LogBlockData['type'] {
switch (lineType) {
case 'tool_call':
return 'tool';
case 'thought':
return 'info';
case 'system':
return 'info';
case 'stderr':
return 'error';
case 'stdout':
case 'metadata':
default:
return 'output';
}
}
/**
* Check if a line type should start a new block
*/
function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean {
// No current block exists
if (!currentBlockType) {
return true;
}
// These types always start new blocks
if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') {
return true;
}
// stderr starts a new block if not already in stderr
if (lineType === 'stderr' && currentBlockType !== 'stderr') {
return true;
}
// tool_call block captures all following stdout/stderr until next tool_call
if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) {
return false;
}
// stderr block captures all stderr until next different type
if (currentBlockType === 'stderr' && lineType === 'stderr') {
return false;
}
// stdout merges into current stdout block
if (currentBlockType === 'stdout' && lineType === 'stdout') {
return false;
}
// Different type - start new block
if (currentBlockType !== lineType) {
return true;
}
return false;
}
/**
* Group CLI output lines into log blocks
*
* Block grouping rules:
* 1. tool_call starts new block, includes following stdout/stderr until next tool_call
* 2. thought becomes independent block
* 3. system becomes independent block
* 4. stderr becomes highlighted block
* 5. Other stdout merges into normal blocks
*/
function groupLinesIntoBlocks(
lines: CliOutputLine[],
executionId: string,
executionStatus: 'running' | 'completed' | 'error'
): LogBlockData[] {
const blocks: LogBlockData[] = [];
let currentLines: LogLine[] = [];
let currentType: string | null = null;
let currentTitle = '';
let currentToolName: string | undefined;
let blockStartTime = 0;
let blockIndex = 0;
for (const line of lines) {
const blockType = getBlockType(line.type);
// Check if we need to start a new block
if (shouldStartNewBlock(line.type, currentType)) {
// Save current block if exists
if (currentLines.length > 0) {
const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined;
blocks.push({
id: `${executionId}-block-${blockIndex}`,
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
type: getBlockType(currentType || ''),
status: executionStatus === 'running' ? 'running' : 'completed',
toolName: currentToolName,
lineCount: currentLines.length,
duration,
lines: currentLines,
timestamp: blockStartTime,
});
blockIndex++;
}
// Start new block
currentType = line.type;
currentTitle = generateBlockTitle(line.type, line.content);
currentLines = [
{
type: line.type,
content: line.content,
timestamp: line.timestamp,
},
];
blockStartTime = line.timestamp;
// Extract tool name for tool_call blocks
if (line.type === 'tool_call') {
const metadata = parseToolCallMetadata(line.content);
currentToolName = metadata?.toolName;
} else {
currentToolName = undefined;
}
} else {
// Add line to current block
currentLines.push({
type: line.type,
content: line.content,
timestamp: line.timestamp,
});
}
}
// Finalize the last block
if (currentLines.length > 0) {
const lastLine = currentLines[currentLines.length - 1];
const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined;
blocks.push({
id: `${executionId}-block-${blockIndex}`,
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
type: getBlockType(currentType || ''),
status: executionStatus === 'running' ? 'running' : 'completed',
toolName: currentToolName,
lineCount: currentLines.length,
duration,
lines: currentLines,
timestamp: blockStartTime,
});
}
return blocks;
}
/**
* Props for LogBlockList component
@@ -219,29 +20,26 @@ export interface LogBlockListProps {
/**
* LogBlockList component
* Displays CLI output grouped into collapsible blocks
*
* Uses the store's getBlocks method to retrieve pre-computed blocks,
* avoiding duplicate logic and ensuring consistent block grouping.
*/
export function LogBlockList({ executionId, className }: LogBlockListProps) {
// Get execution data from store
const executions = useCliStreamStore((state) => state.executions);
// Get blocks directly from store using the getBlocks selector
// This avoids duplicate logic and leverages store-side caching
const blocks = useCliStreamStore(
(state) => executionId ? state.getBlocks(executionId) : [],
(a, b) => a === b // Shallow comparison - arrays are cached in store
);
// Get current execution or execution by ID
const currentExecution = useMemo(() => {
if (!executionId) return null;
return executions[executionId] || null;
}, [executions, executionId]);
// Get execution status for empty state display
const currentExecution = useCliStreamStore((state) =>
executionId ? state.executions[executionId] : null
);
// Manage expanded blocks state
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set());
// Group output lines into blocks
const blocks = useMemo(() => {
if (!currentExecution?.output || currentExecution.output.length === 0) {
return [];
}
return groupLinesIntoBlocks(currentExecution.output, executionId!, currentExecution.status);
}, [currentExecution, executionId]);
// Toggle block expand/collapse
const toggleBlockExpand = useCallback((blockId: string) => {
setExpandedBlocks((prev) => {

View File

@@ -4,4 +4,5 @@
export { LogBlock, default } from './LogBlock';
export { LogBlockList, type LogBlockListProps } from './LogBlockList';
export { getOutputLineClass } from './utils';
export type { LogBlockProps, LogBlockData, LogLine } from './types';

View File

@@ -0,0 +1,187 @@
// ========================================
// LogBlock JSON Utilities
// ========================================
// JSON content detection and formatting utilities
/**
* JSON content type detection result
*/
export interface JsonDetectionResult {
isJson: boolean;
parsed?: unknown;
error?: string;
format: 'object' | 'array' | 'primitive' | 'invalid';
}
/**
* Display mode for JSON content
*/
export type JsonDisplayMode = 'text' | 'card' | 'inline';
/**
* Detect if content is valid JSON
*
* @param content - Content string to check
* @returns Detection result with parsed data if valid
*/
export function detectJson(content: string): JsonDetectionResult {
const trimmed = content.trim();
// Quick check for JSON patterns
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return { isJson: false, format: 'invalid' };
}
try {
const parsed = JSON.parse(trimmed);
// Determine format type
let format: JsonDetectionResult['format'] = 'primitive';
if (Array.isArray(parsed)) {
format = 'array';
} else if (parsed !== null && typeof parsed === 'object') {
format = 'object';
}
return { isJson: true, parsed, format };
} catch (error) {
return {
isJson: false,
format: 'invalid',
error: error instanceof Error ? error.message : 'Unknown parse error'
};
}
}
/**
* Extract JSON from mixed content
* Handles cases where JSON is embedded in text output
*
* @param content - Content that may contain JSON
* @returns Extracted JSON string or null if not found
*/
export function extractJson(content: string): string | null {
const trimmed = content.trim();
// Direct JSON
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Find the matching bracket
let depth = 0;
let inString = false;
let escape = false;
let end = -1;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{' || char === '[') {
depth++;
} else if (char === '}' || char === ']') {
depth--;
if (depth === 0) {
end = i + 1;
break;
}
}
}
}
if (end > 0) {
return trimmed.substring(0, end);
}
}
// Try to find JSON in code blocks
const codeBlockMatch = content.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
if (codeBlockMatch) {
return codeBlockMatch[1].trim();
}
return null;
}
/**
* Detect if content should be displayed as JSON
* Combines extraction and validation
*
* @param content - Content to check
* @returns Detection result
*/
export function detectJsonContent(content: string): JsonDetectionResult & { extracted: string | null } {
const extracted = extractJson(content);
if (!extracted) {
return { isJson: false, format: 'invalid', extracted: null };
}
const result = detectJson(extracted);
return { ...result, extracted };
}
/**
* Format JSON for display
*
* @param data - Parsed JSON data
* @param indent - Indentation spaces (default: 2)
* @returns Formatted JSON string
*/
export function formatJson(data: unknown, indent: number = 2): string {
return JSON.stringify(data, null, indent);
}
/**
* Get a summary string for JSON data
*
* @param data - Parsed JSON data
* @returns Summary description
*/
export function getJsonSummary(data: unknown): string {
if (data === null) return 'null';
if (typeof data === 'boolean') return data ? 'true' : 'false';
if (typeof data === 'number') return String(data);
if (typeof data === 'string') return `"${data.length > 30 ? data.substring(0, 30) + '...' : data}"`;
if (Array.isArray(data)) {
const length = data.length;
return `Array[${length}]${length > 0 ? ` (${getJsonSummary(data[0])}, ...)` : ''}`;
}
if (typeof data === 'object') {
const keys = Object.keys(data);
return `Object{${keys.length}}${keys.length > 0 ? ` (${keys.slice(0, 3).join(', ')}${keys.length > 3 ? ', ...' : ''})` : ''}`;
}
return String(data);
}
/**
* Get color class for JSON value type
*
* @param value - JSON value
* @returns Tailwind color class
*/
export function getJsonValueTypeColor(value: unknown): string {
if (value === null) return 'text-muted-foreground';
if (typeof value === 'boolean') return 'text-purple-400';
if (typeof value === 'number') return 'text-orange-400';
if (typeof value === 'string') return 'text-green-400';
if (Array.isArray(value)) return 'text-blue-400';
if (typeof value === 'object') return 'text-yellow-400';
return 'text-foreground';
}

View File

@@ -0,0 +1,30 @@
// ========================================
// LogBlock Utility Functions
// ========================================
// Shared helper functions for LogBlock components
import type { CliOutputLine } from '@/stores/cliStreamStore';
/**
* Get the CSS class name for a given output line type
*
* @param type - The output line type
* @returns The CSS class name for styling the line
*/
export function getOutputLineClass(type: CliOutputLine['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';
}
}