/** * 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 { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; 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'; type CliLogsOrder = 'oldest-first' | 'newest-first'; interface CliLogsRichViewProps { cliLogsTail: string; order?: CliLogsOrder; onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; containerRefCallback?: (el: HTMLDivElement | null) => void; /** Optional local search query override for inline highlighting */ searchQueryOverride?: 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, searchQueryOverride, }: { group: StreamJsonGroup; expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; }): 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, searchQueryOverride, }: { group: StreamJsonGroup; isExpanded: boolean; onToggle: () => void; expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; }): 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, order = 'oldest-first', onScroll, containerRefCallback, searchQueryOverride, className, }: CliLogsRichViewProps): React.JSX.Element => { const scrollRef = useRef(null); const stickToEdgeRef = useRef(true); const lastOrderRef = useRef(order); // 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]); const computeShouldStickToEdge = useCallback( (el: HTMLDivElement): boolean => { // Small threshold makes it feel "sticky" but still allows reading slightly away from the edge const thresholdPx = 16; if (order === 'newest-first') { return el.scrollTop <= thresholdPx; } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; return distanceFromBottom <= thresholdPx; }, [order] ); // Auto-scroll only when user is pinned to the edge. useEffect(() => { const el = scrollRef.current; if (!el) return; // If the sort order changes, always snap once (expectation: show the "newest edge"). if (lastOrderRef.current !== order) { lastOrderRef.current = order; stickToEdgeRef.current = true; } if (!stickToEdgeRef.current) return; if (order === 'newest-first') { el.scrollTop = 0; return; } el.scrollTop = el.scrollHeight; }, [cliLogsTail, order]); 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 (
{ scrollRef.current = el; containerRefCallback?.(el); }} className={cn( 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', className )} onScroll={(e) => { const el = e.currentTarget; stickToEdgeRef.current = computeShouldStickToEdge(el); onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, }); }} > {hasContent ? (
            {cliLogsTail}
          
) : (

Waiting for CLI output...

)}
); } const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups; return (
{ scrollRef.current = el; containerRefCallback?.(el); }} className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)} onScroll={(e) => { const el = e.currentTarget; stickToEdgeRef.current = computeShouldStickToEdge(el); onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, }); }} > {visibleGroups.map((group) => group.items.length === 1 ? ( // Single item — render flat without collapsible group wrapper ) : ( handleGroupToggle(group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} searchQueryOverride={searchQueryOverride} /> ) )}
); };