/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useMemo, useEffect } from 'react'; import { Box, Text } from 'ink'; import { SemanticColors } from '../colors.js'; import { useResponsive } from '../hooks/useResponsive.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { firstNonEmptyString } from '../../utils/coalesce.js'; import { getBorderStyle } from '../contexts/UnicodeRenderingContext.js'; interface LogEntry { timestamp: string; type: 'request' | 'response' | 'tool_call'; provider: string; model?: string; conversationId?: string; messages?: Array<{ role: string; content: string; }>; response?: string; tokens?: { input?: number; output?: number; }; error?: string; // Tool call specific fields tool?: string; duration?: number; success?: boolean; gitStats?: { linesAdded: number; linesRemoved: number; filesChanged: number; }; } interface LoggingDialogProps { entries: LogEntry[]; onClose: () => void; } const formatTimestamp = (timestamp: string, isNarrow: boolean): string => { const date = new Date(timestamp); if (isNarrow) { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, }); } return date.toLocaleString('en-US', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); }; const formatContent = (content: string, maxLength: number): string => { const cleaned = content.replace(/\s+/g, ' ').trim(); if (cleaned.length <= maxLength) { return cleaned; } return cleaned.substring(0, maxLength - 3) + '...'; }; const formatTokenCount = (tokens?: { input?: number; output?: number; }): string => { if (tokens === undefined) return ''; const parts: string[] = []; if (tokens.input !== undefined) parts.push(`in:${tokens.input}`); if (tokens.output !== undefined) parts.push(`out:${tokens.output}`); return parts.length > 0 ? `[${parts.join(' ')}]` : ''; }; const ENTRY_TYPE_ICONS: Record = { request: '→', tool_call: '[TOOL]', }; const getEntryTypeColor = (type: LogEntry['type']) => { if (type === 'request') { return SemanticColors.text.accent; } if (type === 'tool_call') { return SemanticColors.status.warning; } return SemanticColors.status.success; }; function getEntryMetadata(entry: LogEntry, isNarrow: boolean) { const timestamp = formatTimestamp(entry.timestamp, isNarrow); const typeIcon = ENTRY_TYPE_ICONS[entry.type] ?? '←'; const typeColor = getEntryTypeColor(entry.type); return { timestamp, typeIcon, typeColor }; } function buildToolCallContent(entry: LogEntry): string { let toolContent = `${firstNonEmptyString(entry.tool, 'Unknown tool')}`; if (entry.duration !== undefined && entry.duration > 0) { toolContent += ` (${entry.duration}ms)`; } if (entry.success === false) { toolContent += ' FAILED'; } if (entry.gitStats) { const { linesAdded, linesRemoved, filesChanged } = entry.gitStats; toolContent += ` [+${linesAdded} -${linesRemoved} in ${filesChanged} files]`; } return toolContent; } function getEntryMainContent(entry: LogEntry, contentWidth: number): string { if (entry.type === 'request' && entry.messages) { const lastMessage = entry.messages.at(-1); if (lastMessage) { return formatContent(lastMessage.content, contentWidth); } } else if (entry.type === 'response' && entry.response) { return formatContent(entry.response, contentWidth); } else if (entry.type === 'tool_call') { return formatContent(buildToolCallContent(entry), contentWidth); } else if (entry.error) { return `Error: ${entry.error}`; } return ''; } interface LogEntryRowProps { entry: LogEntry; isSelected: boolean; isNarrow: boolean; dialogWidth: number; globalIndex: number; } const LogEntryRow: React.FC = ({ entry, isSelected, isNarrow, dialogWidth, globalIndex, }) => { const { timestamp, typeIcon, typeColor } = getEntryMetadata(entry, isNarrow); const metadataWidth = timestamp.length + entry.provider.length + 10; const contentWidth = dialogWidth - metadataWidth - 4; const mainContent = getEntryMainContent(entry, contentWidth); const tokens = formatTokenCount(entry.tokens); return ( {isSelected ? '▶' : ' '} {timestamp} {typeIcon} {entry.provider} {entry.model && !isNarrow && ` (${entry.model})`} {tokens && !isNarrow && ( {tokens} )} {mainContent && ( {mainContent} )} ); }; function useLoggingNavigation( reversedEntriesLength: number, contentHeight: number, selectedIndex: number, scrollOffset: number, setSelectedIndex: (fn: (prev: number) => number) => void, setScrollOffset: (fn: (prev: number) => number) => void, onClose: () => void, ) { useKeypress( (key) => { if (key.name === 'escape' || key.sequence === 'q') { onClose(); return; } if (reversedEntriesLength === 0) return; if (key.name === 'up' || key.sequence === 'k') { setSelectedIndex((prev) => Math.max(0, prev - 1)); } if (key.name === 'down' || key.sequence === 'j') { setSelectedIndex((prev) => Math.min(reversedEntriesLength - 1, prev + 1), ); } if (key.name === 'pageup') { setSelectedIndex((prev) => Math.max(0, prev - Math.floor(contentHeight / 2)), ); } if (key.name === 'pagedown') { setSelectedIndex((prev) => Math.min( reversedEntriesLength - 1, prev + Math.floor(contentHeight / 2), ), ); } if (key.sequence === 'g') { setSelectedIndex(() => 0); } if (key.sequence === 'G') { setSelectedIndex(() => reversedEntriesLength - 1); } }, { isActive: true }, ); } function useScrollAdjustment( selectedIndex: number, scrollOffset: number, contentHeight: number, setScrollOffset: (fn: (prev: number) => number) => void, ) { useEffect(() => { const relativeIndex = selectedIndex - scrollOffset; if (relativeIndex < 0) { setScrollOffset(() => selectedIndex); } else if (relativeIndex >= contentHeight) { setScrollOffset(() => Math.max(0, selectedIndex - contentHeight + 1)); } }, [selectedIndex, scrollOffset, contentHeight, setScrollOffset]); } interface LoggingDialogState { isNarrow: boolean; dialogWidth: number; contentHeight: number; reversedEntries: LogEntry[]; visibleEntries: LogEntry[]; selectedIndex: number; scrollOffset: number; hasScroll: boolean; scrollPercentage: number; } function useLoggingDialogState( entries: LogEntry[], onClose: () => void, ): LoggingDialogState { const { isNarrow, width } = useResponsive(); const { rows: height } = useTerminalSize(); const [selectedIndex, setSelectedIndex] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const dialogWidth = isNarrow ? width - 4 : Math.min(width - 8, 120); const dialogHeight = Math.min(height - 6, 30); const contentHeight = dialogHeight - 6; const reversedEntries = useMemo(() => [...entries].reverse(), [entries]); const visibleEntries = useMemo(() => { const start = scrollOffset; const end = Math.min(start + contentHeight, reversedEntries.length); return reversedEntries.slice(start, end); }, [reversedEntries, scrollOffset, contentHeight]); useScrollAdjustment( selectedIndex, scrollOffset, contentHeight, setScrollOffset, ); useLoggingNavigation( reversedEntries.length, contentHeight, selectedIndex, scrollOffset, setSelectedIndex, setScrollOffset, onClose, ); const hasScroll = reversedEntries.length > contentHeight; const scrollPercentage = hasScroll ? Math.round( ((scrollOffset + contentHeight) / reversedEntries.length) * 100, ) : 100; return { isNarrow, dialogWidth, contentHeight, reversedEntries, visibleEntries, selectedIndex, scrollOffset, hasScroll, scrollPercentage, }; } const LoggingDialogHeader: React.FC<{ dialogWidth: number; entryCount: number; }> = ({ dialogWidth, entryCount }) => ( <> Conversation Logs {entryCount} entries {'─'.repeat(dialogWidth - 4)} ); const LoggingDialogFooter: React.FC<{ dialogWidth: number; isNarrow: boolean; hasScroll: boolean; scrollPercentage: number; }> = ({ dialogWidth, isNarrow, hasScroll, scrollPercentage }) => ( <> {'─'.repeat(dialogWidth - 4)} {isNarrow ? '↑↓ Nav' : '↑↓ Navigate'} {!isNarrow && 'g/G Top/Bottom '} ESC Close {hasScroll && ( {scrollPercentage}% )} ); export const LoggingDialog: React.FC = ({ entries, onClose, }) => { const state = useLoggingDialogState(entries, onClose); return ( {state.visibleEntries.length > 0 ? ( state.visibleEntries.map((entry, index) => ( )) ) : ( No log entries found )} ); };