/** * CliLogsRichView * * Renders CLI stream-json logs using the same rich components as session views: * thinking blocks, tool call cards, markdown text output. * * Replaces raw JSON display in ProvisioningProgressBlock. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; import { cn } from '@renderer/lib/utils'; import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; interface CliLogsRichViewProps { cliLogsTail: string; className?: string; } /** * Derives a scoped Set for a single group from the global prefixed Set. * Global keys are stored as `groupId::itemId`; this strips the prefix. */ function scopedItemIds(globalIds: Set, groupId: string): Set { const prefix = `${groupId}::`; const scoped = new Set(); for (const key of globalIds) { if (key.startsWith(prefix)) { scoped.add(key.slice(prefix.length)); } } return scoped; } /** * Single-item group rendered flat (no collapsible wrapper). */ const FlatGroupItem = ({ group, expandedItemIds, onItemClick, }: { group: StreamJsonGroup; expandedItemIds: Set; onItemClick: (itemId: string) => void; }): React.JSX.Element => { const groupItemIds = useMemo( () => scopedItemIds(expandedItemIds, group.id), [expandedItemIds, group.id] ); const handleItemClick = useCallback( (itemId: string) => onItemClick(`${group.id}::${itemId}`), [group.id, onItemClick] ); return ( ); }; /** * A single collapsible group of assistant items (2+ items). */ const StreamGroup = ({ group, isExpanded, onToggle, expandedItemIds, onItemClick, }: { group: StreamJsonGroup; isExpanded: boolean; onToggle: () => void; expandedItemIds: Set; onItemClick: (itemId: string) => void; }): React.JSX.Element => { // Scope item IDs to this group to avoid cross-group collisions const groupItemIds = useMemo( () => scopedItemIds(expandedItemIds, group.id), [expandedItemIds, group.id] ); const handleItemClick = useCallback( (itemId: string) => onItemClick(`${group.id}::${itemId}`), [group.id, onItemClick] ); return (
{isExpanded && (
)}
); }; export const CliLogsRichView = ({ cliLogsTail, className, }: CliLogsRichViewProps): React.JSX.Element => { const scrollRef = useRef(null); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]); // Derive expanded state: all groups expanded unless manually collapsed const expandedGroupIds = useMemo(() => { const expanded = new Set(); for (const group of groups) { if (!collapsedGroupIds.has(group.id)) { expanded.add(group.id); } } return expanded; }, [groups, collapsedGroupIds]); // Auto-scroll to bottom on new content useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [cliLogsTail]); const handleGroupToggle = useCallback((groupId: string) => { setCollapsedGroupIds((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }, []); const handleItemClick = useCallback((itemId: string) => { setExpandedItemIds((prev) => { const next = new Set(prev); if (next.has(itemId)) { next.delete(itemId); } else { next.add(itemId); } return next; }); }, []); if (groups.length === 0) { // cliLogsTail has data but no parseable assistant messages — show raw text fallback const hasContent = cliLogsTail.trim().length > 0; return (
{hasContent ? (
            {cliLogsTail}
          
) : (

Waiting for CLI output...

)}
); } return (
{groups.map((group) => group.items.length === 1 ? ( // Single item — render flat without collapsible group wrapper ) : ( handleGroupToggle(group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} /> ) )}
); };