diff --git a/apps/web/app/global.css b/apps/web/app/global.css index ed65813..d41d949 100644 --- a/apps/web/app/global.css +++ b/apps/web/app/global.css @@ -232,6 +232,15 @@ animation-delay: var(--unrag-reveal-delay, 0ms); } +.no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.no-scrollbar::-webkit-scrollbar { + display: none; +} + @media (prefers-reduced-motion: reduce) { .unrag-fade-in { animation: none; diff --git a/apps/web/components/elements.tsx b/apps/web/components/elements.tsx index 02326e8..0b70dce 100644 --- a/apps/web/components/elements.tsx +++ b/apps/web/components/elements.tsx @@ -1,4 +1,4 @@ -import {clsx} from 'clsx/lite' +import {cn} from '@/lib/utils' import Link from 'next/link' import type {ComponentProps, ReactNode} from 'react' import {ChevronIcon} from './icons' @@ -26,8 +26,8 @@ export function AnnouncementBadge({ href={href} {...props} data-variant={variant} - className={clsx( - 'group relative inline-flex max-w-full gap-x-3 overflow-hidden rounded-md px-3.5 py-2 text-sm/6 max-sm:flex-col sm:items-center sm:rounded-full sm:px-3 sm:py-0.5', + className={cn( + 'group relative inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 overflow-hidden rounded-md px-2 py-1.5 text-xs/5 sm:gap-x-3 sm:gap-y-0 sm:rounded-full sm:px-3 sm:py-0.5 sm:text-sm/6', variant === 'normal' && 'bg-olive-950/5 text-olive-950 hover:bg-olive-950/10 dark:bg-white/5 dark:text-white dark:inset-ring-1 dark:inset-ring-white/5 dark:hover:bg-white/10', variant === 'overlay' && @@ -37,15 +37,15 @@ export function AnnouncementBadge({ > {text} @@ -68,7 +68,7 @@ export function Button({ return ( + ) + })} + + + ) +} + +function ChunkHistogram() { + const maxCount = Math.max(...mockChunkRanges.map((r) => r.count), 1) + + return ( +
+
+ Chunk Size Distribution +
+
+ {mockChunkRanges.map((range) => { + const height = + range.count > 0 ? (range.count / maxCount) * 100 : 4 + return ( +
+
0 + ? theme.accent + : 'rgba(255,255,255,0.1)' + }} + /> + + {range.range} + +
+ ) + })} +
+
+ ) +} + +function ChunkDetails() { + const {selectedDocIndex, selectedChunkIndex, setSelectedChunkIndex} = + useTerminal() + const doc = mockDocuments[selectedDocIndex] + const chunks = doc + ? mockChunkDetails[doc.sourceId] || + Array.from({length: doc.chunks}, (_, i) => ({ + idx: i, + tokens: 150 + Math.floor(Math.random() * 100), + content: `Chunk ${i + 1} content preview for ${doc.sourceId}...` + })) + : [] + + return ( +
+
+ Chunks ({chunks.length}) +
+ {chunks.map((chunk) => { + const isSelected = chunk.idx === selectedChunkIndex + return ( + + ) + })} +
+ ) +} + +function DocumentDetails() { + return ( +
+ + +
+ ) +} + +export function TerminalDocs() { + return ( +
+ + +
+ ) +} diff --git a/apps/web/components/terminal/terminal-events.tsx b/apps/web/components/terminal/terminal-events.tsx new file mode 100644 index 0000000..42daaf0 --- /dev/null +++ b/apps/web/components/terminal/terminal-events.tsx @@ -0,0 +1,314 @@ +'use client' + +import {cn} from '@/lib/utils' +import {useHotkeys} from '@mantine/hooks' +import {useState} from 'react' +import {useTerminal} from './terminal-context' +import {chars, theme} from './terminal-theme' +import type {TerminalEvent} from './terminal-types' + +const filters = [ + {id: 'all', label: 'ALL', shortcut: 'a'}, + {id: 'ingest', label: 'ingest', shortcut: 'i'}, + {id: 'retrieve', label: 'retrieve', shortcut: 'r'}, + {id: 'rerank', label: 'rerank', shortcut: 'k'}, + {id: 'delete', label: 'delete', shortcut: 'd'} +] + +function isEditableTarget() { + const active = document.activeElement + if (!active) { + return false + } + if (active instanceof HTMLInputElement) { + return true + } + if (active instanceof HTMLTextAreaElement) { + return true + } + if (active instanceof HTMLElement && active.isContentEditable) { + return true + } + return false +} + +export function TerminalEvents() { + const {events, activeTab, hasUserInteracted, stopAllAnimations} = + useTerminal() + const [selectedIndex, setSelectedIndex] = useState(0) + const [activeFilter, setActiveFilter] = useState('all') + + // Filter events based on active filter + const filteredEvents = + activeFilter === 'all' + ? events + : events.filter((e) => e.type.startsWith(activeFilter)) + + const selectedEvent = filteredEvents[selectedIndex] as + | TerminalEvent + | undefined + const queryLabel = selectedEvent?.type.startsWith('ingest') + ? 'source' + : 'query' + + useHotkeys([ + [ + 'j', + () => { + if (activeTab !== 'events' || !hasUserInteracted) { + return + } + if (isEditableTarget() || filteredEvents.length === 0) { + return + } + stopAllAnimations() + setSelectedIndex((prev) => + Math.min(prev + 1, filteredEvents.length - 1) + ) + }, + {preventDefault: true} + ], + [ + 'k', + () => { + if (activeTab !== 'events' || !hasUserInteracted) { + return + } + if (isEditableTarget() || filteredEvents.length === 0) { + return + } + stopAllAnimations() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + }, + {preventDefault: true} + ] + ]) + + // Empty state + if (events.length === 0) { + return ( +
+ {/* Filter bar */} +
+
+ {filters.map((filter) => { + const isActive = activeFilter === filter.id + return ( + + ) + })} +
+ + 0 + +
+ + {/* Empty state */} +
+ No events yet. Run a query to see events. +
+
+ ) + } + + return ( +
+ {/* Filter bar */} +
+
+ {filters.map((filter) => { + const isActive = activeFilter === filter.id + return ( + + ) + })} +
+ + {filteredEvents.length} + +
+ + {/* Main content */} +
+ {/* Events list */} +
+
+
+ + EVENTS + + + j/k navigate · enter inspect + +
+ + 1-{filteredEvents.length} of {filteredEvents.length} + +
+
+ {filteredEvents.map((event, idx) => { + const isSelected = idx === selectedIndex + const isComplete = event.type.includes('complete') + return ( + + ) + })} +
+
+ + {/* Details panel */} +
+
+ + DETAILS + +
+ {selectedEvent && ( +
+
+ + {selectedEvent.type.toUpperCase()} + + + {selectedEvent.time} + +
+
+ {selectedEvent.query && ( +
+ + {queryLabel} + + + {selectedEvent.query} + +
+ )} + {selectedEvent.results && ( +
+ + results + + + {selectedEvent.results} + +
+ )} + {selectedEvent.embed && ( +
+ + embed + + + {selectedEvent.embed} + +
+ )} + {selectedEvent.db && ( +
+ + db + + + {selectedEvent.db} + +
+ )} + {selectedEvent.total && ( +
+ + total + + + {selectedEvent.total} + +
+ )} +
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-header.tsx b/apps/web/components/terminal/terminal-header.tsx new file mode 100644 index 0000000..c9d69ba --- /dev/null +++ b/apps/web/components/terminal/terminal-header.tsx @@ -0,0 +1,35 @@ +'use client' + +import {mockSessionId} from './terminal-mock-data' +import {chars, theme} from './terminal-theme' + +export function TerminalHeader() { + const shortSessionId = mockSessionId.slice(0, 8) + + return ( +
+
+ + Unrag debug + + + session {shortSessionId}... + +
+
+ + {chars.dot} + + + LIVE + +
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-ingest.tsx b/apps/web/components/terminal/terminal-ingest.tsx new file mode 100644 index 0000000..ac71a0c --- /dev/null +++ b/apps/web/components/terminal/terminal-ingest.tsx @@ -0,0 +1,190 @@ +'use client' + +import {cn} from '@/lib/utils' +import {useState} from 'react' +import {useTerminal} from './terminal-context' +import {theme} from './terminal-theme' + +const DEFAULT_SOURCE_ID = 'debug:hello-world' +const DEFAULT_CONTENT = + 'Hello from Unrag Debug TUI. This content is chunked, embedded, and stored so queries can retrieve matching passages.' +const DEFAULT_METADATA = '{"source":"debug"}' + +export function TerminalIngest() { + const {ingestDocument, ingestedDocuments, stopAllAnimations, isIngesting} = + useTerminal() + const [sourceId, setSourceId] = useState(DEFAULT_SOURCE_ID) + const [content, setContent] = useState(DEFAULT_CONTENT) + const [metadata, setMetadata] = useState(DEFAULT_METADATA) + const [chunkSize, setChunkSize] = useState(120) + const [overlap, setOverlap] = useState(20) + + const recentLogs = ingestedDocuments.slice(0, 4) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + stopAllAnimations() + ingestDocument({ + sourceId, + content, + metadata, + chunkSize, + overlap + }) + } + + const handleClear = () => { + stopAllAnimations() + setContent('') + } + + return ( +
+
+ INGEST tab fields · e edit · r run · c clear +
+
+
+
+ + + +
+
+ + + + mode inline + +
+
+
+ + +
+
+ {isIngesting ? ( + Ingesting... + ) : recentLogs.length ? ( + recentLogs.map((log) => ( +
+ + Ingested {log.sourceId} + + + {log.chunkCount} chunks · {log.time} + +
+ )) + ) : ( + + No ingests yet. Add content to make it searchable. + + )} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-logo.tsx b/apps/web/components/terminal/terminal-logo.tsx new file mode 100644 index 0000000..127b47f --- /dev/null +++ b/apps/web/components/terminal/terminal-logo.tsx @@ -0,0 +1,17 @@ +'use client' + +import Image from 'next/image' + +export function TerminalLogo() { + return ( +
+ Unrag +
+ ) +} diff --git a/apps/web/components/terminal/terminal-mock-data.ts b/apps/web/components/terminal/terminal-mock-data.ts new file mode 100644 index 0000000..7842def --- /dev/null +++ b/apps/web/components/terminal/terminal-mock-data.ts @@ -0,0 +1,140 @@ +/** + * Mock data for the Terminal component demo. + */ + +import type { + ChunkDetail, + ChunkRange, + DocumentItem, + TerminalStats +} from './terminal-types' + +export const mockStats: TerminalStats = { + adapter: 'drizzle', + vectors: 164, + dim: 1536, + documents: 26, + chunks: 164, + embeddings: 164 +} + +export const mockDocuments: DocumentItem[] = [ + {sourceId: 'blog:resend:how-to-send-emails-using-bun', chunks: 4}, + {sourceId: 'blog:resend:improving-time-to-inbox-in-asia', chunks: 3}, + {sourceId: 'blog:resend:introducing-broadcast', chunks: 5}, + {sourceId: 'blog:resend:resend-for-startups', chunks: 2}, + {sourceId: 'blog:resend:building-email-infrastructure', chunks: 6}, + {sourceId: 'blog:resend:react-email-2-0', chunks: 4}, + {sourceId: 'blog:resend:email-authentication-guide', chunks: 7}, + {sourceId: 'blog:resend:smtp-vs-api', chunks: 3}, + {sourceId: 'blog:resend:deliverability-best-practices', chunks: 8}, + {sourceId: 'blog:resend:domain-verification', chunks: 4}, + {sourceId: 'blog:resend:webhook-events', chunks: 5}, + {sourceId: 'blog:resend:batch-emails', chunks: 3}, + {sourceId: 'blog:resend:email-templates', chunks: 6}, + {sourceId: 'blog:resend:attachments-guide', chunks: 4}, + {sourceId: 'blog:resend:rate-limiting', chunks: 2}, + {sourceId: 'blog:resend:error-handling', chunks: 5}, + {sourceId: 'blog:resend:testing-emails', chunks: 3}, + {sourceId: 'blog:resend:scheduling-emails', chunks: 4}, + {sourceId: 'blog:resend:email-analytics', chunks: 6}, + {sourceId: 'blog:resend:custom-headers', chunks: 2}, + {sourceId: 'blog:resend:reply-to-setup', chunks: 3}, + {sourceId: 'blog:resend:unsubscribe-links', chunks: 4}, + {sourceId: 'blog:resend:email-preview', chunks: 5}, + {sourceId: 'blog:resend:dark-mode-emails', chunks: 3}, + {sourceId: 'blog:resend:responsive-design', chunks: 7}, + {sourceId: 'blog:resend:accessibility-guide', chunks: 4} +] + +export const mockChunkRanges: ChunkRange[] = [ + {range: '<50', count: 0}, + {range: '50-99', count: 1}, + {range: '100-199', count: 0}, + {range: '200-399', count: 3}, + {range: '400-799', count: 0}, + {range: '800+', count: 0} +] + +export const mockChunkDetails: Record = { + 'blog:resend:how-to-send-emails-using-bun': [ + { + idx: 0, + tokens: 200, + content: + '# How to send emails using Bun\n\nRendering React emails and sending them with Resend is incredibly fast with Bun runtime...' + }, + { + idx: 1, + tokens: 185, + content: + '## Setting up the project\n\nFirst, create a new Bun project and install the required dependencies...' + }, + { + idx: 2, + tokens: 220, + content: + '## Creating the email template\n\nUsing React Email components, we can build beautiful templates...' + }, + { + idx: 3, + tokens: 175, + content: + '## Sending the email\n\nWith the Resend SDK, sending is as simple as calling resend.emails.send()...' + } + ], + 'blog:resend:improving-time-to-inbox-in-asia': [ + { + idx: 0, + tokens: 245, + content: + "# Improving Time to Inbox in Asia\n\nWe've optimized our infrastructure for faster email delivery across Asian markets..." + }, + { + idx: 1, + tokens: 190, + content: + '## New regional endpoints\n\nOur Singapore and Tokyo data centers now serve as primary routing points...' + }, + { + idx: 2, + tokens: 210, + content: + '## Benchmark results\n\nAverage delivery time improved from 4.2s to 1.8s for major providers...' + } + ], + 'blog:resend:introducing-broadcast': [ + { + idx: 0, + tokens: 180, + content: + '# Introducing Broadcast\n\nA new way to send marketing emails at scale with Resend...' + }, + { + idx: 1, + tokens: 195, + content: + '## Features overview\n\nBroadcast includes audience segmentation, A/B testing, and analytics...' + }, + { + idx: 2, + tokens: 220, + content: + '## Getting started\n\nCreate your first broadcast campaign in just a few steps...' + }, + { + idx: 3, + tokens: 165, + content: + '## Pricing\n\nBroadcast is included in all paid plans starting at 10,000 contacts...' + }, + { + idx: 4, + tokens: 200, + content: + '## Migration guide\n\nMoving from other email marketing platforms is straightforward...' + } + ] +} + +export const mockSessionId = 'cb1b711d-8f2e-4a5c-b9d3-2e8f1a3c4b5d' diff --git a/apps/web/components/terminal/terminal-query.tsx b/apps/web/components/terminal/terminal-query.tsx new file mode 100644 index 0000000..5dc5110 --- /dev/null +++ b/apps/web/components/terminal/terminal-query.tsx @@ -0,0 +1,412 @@ +'use client' + +import {cn} from '@/lib/utils' +import {useHotkeys} from '@mantine/hooks' +import {motion, useReducedMotion} from 'motion/react' +import {useEffect, useRef, useState} from 'react' +import {useTerminal} from './terminal-context' +import {chars, theme} from './terminal-theme' + +const EXAMPLE_QUERY = 'how does unrag ingest work?' +const TYPING_SPEED = 60 + +function isEditableTarget() { + const active = document.activeElement + if (!active) { + return false + } + if (active instanceof HTMLInputElement) { + return true + } + if (active instanceof HTMLTextAreaElement) { + return true + } + if (active instanceof HTMLElement && active.isContentEditable) { + return true + } + return false +} + +export function TerminalQuery() { + const { + activeTab, + queryResults, + lastQuery, + runQuery, + hasUserInteracted, + stopAllAnimations, + isQuerying + } = useTerminal() + const [query, setQuery] = useState(lastQuery || '') + const [isTyping, setIsTyping] = useState(!lastQuery && !hasUserInteracted) + const [selectedIndex, setSelectedIndex] = useState(0) + const [hasInteracted, setHasInteracted] = useState( + !!lastQuery || hasUserInteracted + ) + const inputRef = useRef(null) + const cycleCountRef = useRef(0) + const lastQueryRef = useRef(lastQuery) + const shouldReduceMotion = useReducedMotion() + + // Stop typing if global interaction happened + useEffect(() => { + if (hasUserInteracted) { + setHasInteracted(true) + setIsTyping(false) + } + }, [hasUserInteracted]) + + // Sync query with lastQuery from context when component mounts + useEffect(() => { + if (lastQuery && !query && !hasInteracted && !hasUserInteracted) { + setQuery(lastQuery) + setHasInteracted(true) + setIsTyping(false) + } + }, [lastQuery, query, hasInteracted, hasUserInteracted]) + + useEffect(() => { + if (activeTab !== 'query') { + return + } + if (hasUserInteracted || hasInteracted || lastQuery) { + return + } + setQuery('') + setIsTyping(true) + }, [activeTab, hasUserInteracted, hasInteracted, lastQuery]) + + // Typing animation effect + useEffect(() => { + if (!isTyping || hasInteracted || hasUserInteracted) { + return + } + + if (shouldReduceMotion) { + setQuery(EXAMPLE_QUERY) + setIsTyping(false) + runQuery(EXAMPLE_QUERY) + return + } + + let charIndex = 0 + const typeInterval = setInterval(() => { + if (charIndex < EXAMPLE_QUERY.length) { + setQuery(EXAMPLE_QUERY.slice(0, charIndex + 1)) + charIndex++ + } else { + clearInterval(typeInterval) + setIsTyping(false) + runQuery(EXAMPLE_QUERY) + } + }, TYPING_SPEED) + + return () => { + clearInterval(typeInterval) + } + }, [ + isTyping, + hasInteracted, + hasUserInteracted, + runQuery, + shouldReduceMotion + ]) + + const handleFocus = () => { + setHasInteracted(true) + setIsTyping(false) + stopAllAnimations() + } + + const handleChange = (e: React.ChangeEvent) => { + setHasInteracted(true) + setIsTyping(false) + stopAllAnimations() + setQuery(e.target.value) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setHasInteracted(true) + setIsTyping(false) + stopAllAnimations() + if (query.trim()) { + runQuery(query) + } + } + + useHotkeys([ + [ + 'j', + () => { + if (activeTab !== 'query' || !hasUserInteracted) { + return + } + if (isEditableTarget()) { + return + } + if (queryResults.length === 0) { + return + } + stopAllAnimations() + setHasInteracted(true) + setIsTyping(false) + setSelectedIndex((prev) => + Math.min(prev + 1, queryResults.length - 1) + ) + }, + {preventDefault: true} + ], + [ + 'k', + () => { + if (activeTab !== 'query' || !hasUserInteracted) { + return + } + if (isEditableTarget()) { + return + } + if (queryResults.length === 0) { + return + } + stopAllAnimations() + setHasInteracted(true) + setIsTyping(false) + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + }, + {preventDefault: true} + ] + ]) + + // Show results if we have any in context + const showResults = !isQuerying && queryResults.length > 0 + const selectedResult = queryResults[selectedIndex] + const emptyMessage = isQuerying + ? 'Querying...' + : isTyping + ? 'Typing query...' + : lastQuery + ? 'No results for query' + : 'Enter a query and press SEARCH' + const listVariants = { + hidden: {}, + show: { + transition: { + staggerChildren: shouldReduceMotion ? 0 : 0.06, + delayChildren: shouldReduceMotion ? 0 : 0.05 + } + } + } + const itemVariants = { + hidden: {opacity: 0, y: shouldReduceMotion ? 0 : 6}, + show: { + opacity: 1, + y: 0, + transition: { + duration: shouldReduceMotion ? 0 : 0.18 + } + } + } + + useEffect(() => { + if (lastQueryRef.current === lastQuery) { + return + } + lastQueryRef.current = lastQuery + setSelectedIndex(0) + cycleCountRef.current = 0 + }, [lastQuery]) + + useEffect(() => { + if (!showResults || hasInteracted || hasUserInteracted) { + return + } + if (queryResults.length < 2) { + return + } + + cycleCountRef.current = 0 + + const interval = setInterval(() => { + setSelectedIndex((prev) => (prev + 1) % queryResults.length) + cycleCountRef.current += 1 + if (cycleCountRef.current >= 4) { + clearInterval(interval) + } + }, 1200) + + return () => clearInterval(interval) + }, [showResults, hasInteracted, hasUserInteracted, queryResults.length]) + + return ( +
+ {/* Query input */} +
+
+ + {chars.arrow} + +
+ + {isTyping && !hasInteracted && !hasUserInteracted && ( + + )} +
+ + k=8 + + +
+
+ + {/* Results area */} +
+ {/* Results list */} +
+
+
+ + RESULTS + + + j/k navigate · enter inspect + +
+ {isQuerying ? ( + + QUERYING... + + ) : showResults ? ( + + {queryResults.length} + + ) : null} +
+
+ {showResults ? ( + + {queryResults.map((result, idx) => { + const isSelected = idx === selectedIndex + return ( + + setSelectedIndex(idx) + } + className={cn( + 'w-full text-left px-3 py-1.5 text-[9px] flex items-center gap-3 transition-colors sm:px-4 sm:text-[10px]', + isSelected + ? 'text-white font-medium' + : 'text-white/50 hover:text-white/70' + )} + > + + {chars.check} + + + {result.score.toFixed(2)} + + + {result.sourceId} + + + ) + })} + + ) : ( +
+ {emptyMessage} +
+ )} +
+
+ + {/* Details panel */} +
+
+ + PREVIEW + +
+ {showResults && selectedResult ? ( +
+
+ + {selectedResult.score.toFixed(2)} + + relevance +
+
+
+ + source + + + {selectedResult.sourceId} + +
+
+ + chunk + + + {selectedResult.content} + +
+
+
+ ) : ( +
+ {isQuerying + ? 'Querying...' + : 'Select a result to view details'} +
+ )} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-status-bar.tsx b/apps/web/components/terminal/terminal-status-bar.tsx new file mode 100644 index 0000000..27bce72 --- /dev/null +++ b/apps/web/components/terminal/terminal-status-bar.tsx @@ -0,0 +1,29 @@ +'use client' + +import {theme} from './terminal-theme' + +const shortcuts = [ + {key: '1-6', action: 'tabs'}, + {key: 'j/k', action: 'navigate'}, + {key: 'enter', action: 'select'}, + {key: '?', action: 'help'}, + {key: 'q', action: 'quit'} +] + +export function TerminalStatusBar() { + return ( +
+ {shortcuts.map((shortcut) => ( +
+ + {shortcut.key} + + {shortcut.action} +
+ ))} +
+ ) +} diff --git a/apps/web/components/terminal/terminal-tab-bar.tsx b/apps/web/components/terminal/terminal-tab-bar.tsx new file mode 100644 index 0000000..51b687d --- /dev/null +++ b/apps/web/components/terminal/terminal-tab-bar.tsx @@ -0,0 +1,38 @@ +'use client' + +import {clsx} from 'clsx/lite' +import {useTerminal} from './terminal-context' +import {TABS, theme} from './terminal-theme' +import type {TabId} from './terminal-types' + +export function TerminalTabBar() { + const {activeTab, setActiveTab} = useTerminal() + + return ( +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.id + return ( + + ) + })} +
+ ) +} diff --git a/apps/web/components/terminal/terminal-theme.ts b/apps/web/components/terminal/terminal-theme.ts new file mode 100644 index 0000000..b3a438a --- /dev/null +++ b/apps/web/components/terminal/terminal-theme.ts @@ -0,0 +1,66 @@ +/** + * Theme constants for the Terminal component. + * Based on the debug TUI theme from /packages/unrag/registry/debug/tui/theme.ts + */ + +export const theme = { + // Primary text + fg: '#ffffff', + // Secondary/dim text + muted: 'rgba(255, 255, 255, 0.55)', + dim: 'rgba(255, 255, 255, 0.4)', + // Borders and separators + border: 'rgba(255, 255, 255, 0.15)', + borderActive: '#ffffff', + // Accent color (selection, active states) + accent: '#8AA170', + accentBg: '#8AA170', + // Background colors + panelBg: '#141411', + headerBg: 'white', + // Status colors + success: '#22c55e', + warning: '#eab308', + error: '#ef4444', + // Event type colors + ingest: '#8AA170', + retrieve: '#8AA170' +} as const + +export const chars = { + // Horizontal/vertical lines + h: '─', + v: '│', + // Corners + tl: '┌', + tr: '┐', + bl: '└', + br: '┘', + // T-junctions + lt: '├', + rt: '┤', + tt: '┬', + bt: '┴', + // Cross + x: '┼', + // Bullets and indicators + dot: '●', + circle: '○', + arrow: '›', + pointer: '›', + check: '✓', + cross: '✗', + // Section markers + section: '▸', + // Solid block (for logo rendering) + fullBlock: '█' +} as const + +export const TABS = [ + {id: 'dashboard', label: 'Dashboard', shortcut: 1}, + {id: 'events', label: 'Events', shortcut: 2}, + {id: 'traces', label: 'Traces', shortcut: 3}, + {id: 'query', label: 'Query', shortcut: 4}, + {id: 'docs', label: 'DOCS', shortcut: 5}, + {id: 'ingest', label: 'Ingest', shortcut: 6} +] as const diff --git a/apps/web/components/terminal/terminal-traces.tsx b/apps/web/components/terminal/terminal-traces.tsx new file mode 100644 index 0000000..5c204bb --- /dev/null +++ b/apps/web/components/terminal/terminal-traces.tsx @@ -0,0 +1,250 @@ +'use client' + +import {cn} from '@/lib/utils' +import {useHotkeys} from '@mantine/hooks' +import {useState} from 'react' +import {useTerminal} from './terminal-context' +import {chars} from './terminal-theme' +import type {TerminalTrace} from './terminal-types' + +function isEditableTarget() { + const active = document.activeElement + if (!active) { + return false + } + if (active instanceof HTMLInputElement) { + return true + } + if (active instanceof HTMLTextAreaElement) { + return true + } + if (active instanceof HTMLElement && active.isContentEditable) { + return true + } + return false +} + +function WaterfallBar({ + stages, + totalMs +}: { + stages: TerminalTrace['stages'] + totalMs: number +}) { + const maxWidth = 200 + return ( +
+ {stages.map((stage) => { + const width = Math.max(20, (stage.ms / totalMs) * maxWidth) + return ( +
+ + {stage.name} + + + {stage.ms}ms + +
+
+ ) + })} +
+ ) +} + +export function TerminalTraces() { + const {traces, activeTab, hasUserInteracted, stopAllAnimations} = + useTerminal() + const [selectedIndex, setSelectedIndex] = useState(0) + const selectedTrace = traces[selectedIndex] as TerminalTrace | undefined + + useHotkeys([ + [ + 'j', + () => { + if (activeTab !== 'traces' || !hasUserInteracted) { + return + } + if (isEditableTarget() || traces.length === 0) { + return + } + stopAllAnimations() + setSelectedIndex((prev) => + Math.min(prev + 1, traces.length - 1) + ) + }, + {preventDefault: true} + ], + [ + 'k', + () => { + if (activeTab !== 'traces' || !hasUserInteracted) { + return + } + if (isEditableTarget() || traces.length === 0) { + return + } + stopAllAnimations() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + }, + {preventDefault: true} + ] + ]) + + // Empty state + if (traces.length === 0) { + return ( +
+ {/* Header */} +
+
+ + TRACES + + + j/k navigate + +
+ + 0 + +
+ + {/* Empty state */} +
+ No traces yet. Run a query to see traces. +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + TRACES + + + j/k navigate + +
+ + {traces.length} + +
+ + {/* Main content */} +
+ {/* Traces list */} +
+
+ {traces.map((trace, idx) => { + const isSelected = idx === selectedIndex + return ( + + ) + })} +
+
+ + {/* Details panel */} +
+ {selectedTrace && ( +
+ {/* Waterfall header */} +
+ + WATERFALL + + + {selectedTrace.id} + +
+ + {/* Stage bars */} + + + {/* Events section */} +
+
+ + EVENTS + + + {selectedTrace.events.length} shown + +
+
+ {selectedTrace.events.map((event) => ( +
+ + {event.time} + + + {event.type} + +
+ ))} +
+
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-types.ts b/apps/web/components/terminal/terminal-types.ts new file mode 100644 index 0000000..13252e7 --- /dev/null +++ b/apps/web/components/terminal/terminal-types.ts @@ -0,0 +1,142 @@ +/** + * TypeScript interfaces for the Terminal component. + */ + +import type {CSSProperties, PointerEventHandler} from 'react' + +export type TabId = + | 'dashboard' + | 'events' + | 'traces' + | 'query' + | 'docs' + | 'ingest' + +export interface Tab { + id: TabId + label: string + shortcut: number +} + +export interface TerminalEvent { + id: string + type: string + time: string + duration: string + results?: string + query?: string + embed?: string + db?: string + total?: string +} + +export interface TerminalTrace { + id: string + opName: string + time: string + label: string + totalMs: string + stages: Array<{ + name: string + ms: number + color: string + }> + events: Array<{ + time: string + type: string + }> +} + +export interface QueryResult { + id: string + sourceId: string + score: number + content: string +} + +export interface IngestInput { + sourceId: string + content: string + metadata?: string + chunkSize?: number + overlap?: number +} + +export interface IngestChunk { + id: string + sourceId: string + content: string + tokens: number +} + +export interface IngestDocument { + id: string + sourceId: string + content: string + metadata: string + chunkCount: number + time: string +} + +export interface TerminalState { + activeTab: TabId + selectedDocIndex: number + selectedChunkIndex: number + isAnimating: boolean + isQuerying: boolean + isIngesting: boolean + events: TerminalEvent[] + traces: TerminalTrace[] + queryResults: QueryResult[] + lastQuery: string + hasUserInteracted: boolean + ingestedDocuments: IngestDocument[] + ingestedChunks: IngestChunk[] +} + +export interface TerminalActions { + setActiveTab: (tab: TabId) => void + setSelectedDocIndex: (index: number) => void + setSelectedChunkIndex: (index: number) => void + setIsAnimating: (animating: boolean) => void + runQuery: (query: string) => void + stopAllAnimations: () => void + resetTerminal: () => void + ingestDocument: (input: IngestInput) => void +} + +export interface TerminalContextValue extends TerminalState, TerminalActions {} + +export interface DocumentItem { + sourceId: string + chunks: number +} + +export interface ChunkRange { + range: string + count: number +} + +export interface ChunkDetail { + idx: number + tokens: number + content: string +} + +export interface TerminalStats { + adapter: string + vectors: number + dim: number + documents: number + chunks: number + embeddings: number +} + +export interface TerminalProps { + className?: string + autoPlay?: boolean + initialTab?: TabId + onTitleBarPointerDown?: PointerEventHandler + style?: CSSProperties + resizable?: boolean +} diff --git a/apps/web/components/terminal/terminal.tsx b/apps/web/components/terminal/terminal.tsx new file mode 100644 index 0000000..fa99a43 --- /dev/null +++ b/apps/web/components/terminal/terminal.tsx @@ -0,0 +1,317 @@ +'use client' + +import {cn} from '@/lib/utils' +import {useHotkeys} from '@mantine/hooks' +import {AnimatePresence, motion} from 'motion/react' +import type {PointerEventHandler} from 'react' +import {useCallback, useEffect, useRef, useState} from 'react' +import {TerminalContent} from './terminal-content' +import {TerminalProvider, useTerminal} from './terminal-context' +import {TerminalHeader} from './terminal-header' +import {TerminalLogo} from './terminal-logo' +import {TerminalStatusBar} from './terminal-status-bar' +import {TerminalTabBar} from './terminal-tab-bar' +import {TABS} from './terminal-theme' +import type {TabId, TerminalProps} from './terminal-types' + +const COMMAND = 'bunx unrag debug' +const TYPING_SPEED = 50 +const TUI_REVEAL_DELAY = 300 +const AUTO_INGEST = { + sourceId: 'debug:hello-world', + content: + 'Hello from Unrag Debug TUI. Ingesting content creates chunks so queries can retrieve matching passages.', + metadata: '{"source":"debug"}', + chunkSize: 120, + overlap: 20 +} + +function isEditableTarget() { + const active = document.activeElement + if (!active) { + return false + } + if (active instanceof HTMLInputElement) { + return true + } + if (active instanceof HTMLTextAreaElement) { + return true + } + if (active instanceof HTMLElement && active.isContentEditable) { + return true + } + return false +} + +function TerminalTitleBar({ + onPointerDown +}: { + onPointerDown?: PointerEventHandler +}) { + return ( +
+
+ + + +
+ + Terminal + +
+ ) +} + +function TerminalPrompt({ + command, + isTyping, + showCursor = true +}: { + command: string + isTyping: boolean + showCursor?: boolean +}) { + return ( +
+ vercel + $ + {command} + {isTyping && showCursor && ( + + )} +
+ ) +} + +function TerminalTUI({onInteraction}: {onInteraction: () => void}) { + const { + setSelectedDocIndex, + setActiveTab, + stopAllAnimations, + hasUserInteracted, + resetTerminal, + ingestDocument + } = useTerminal() + const autoPlayRef = useRef | null>(null) + const [isAutoPlaying, setIsAutoPlaying] = useState(true) + + const stopAutoPlay = useCallback(() => { + if (autoPlayRef.current) { + clearTimeout(autoPlayRef.current) + autoPlayRef.current = null + } + setIsAutoPlaying(false) + stopAllAnimations() + onInteraction() + }, [onInteraction, stopAllAnimations]) + + useHotkeys( + TABS.map((tab) => [ + String(tab.shortcut), + () => { + if (!hasUserInteracted || isEditableTarget()) { + return + } + stopAllAnimations() + setActiveTab(tab.id as TabId) + }, + {preventDefault: true} + ]) + ) + + // Also stop if user interacted elsewhere + useEffect(() => { + if (hasUserInteracted && isAutoPlaying) { + if (autoPlayRef.current) { + clearTimeout(autoPlayRef.current) + autoPlayRef.current = null + } + setIsAutoPlaying(false) + } + }, [hasUserInteracted, isAutoPlaying]) + + useEffect(() => { + if (!isAutoPlaying) { + return + } + + resetTerminal() + setActiveTab('dashboard' as TabId) + + const sequence: Array<{action: () => void; delay: number}> = [ + // Show ingest and run a sample document + {action: () => setActiveTab('ingest' as TabId), delay: 1400}, + {action: () => ingestDocument(AUTO_INGEST), delay: 1000}, + // Let ingest logs show before moving on + {action: () => setActiveTab('ingest' as TabId), delay: 3200}, + // Show query and let it type + run + {action: () => setActiveTab('query' as TabId), delay: 1400}, + // Wait for query loading + results before switching + {action: () => setActiveTab('query' as TabId), delay: 10000}, + // Show traces with waterfall + {action: () => setActiveTab('traces' as TabId), delay: 2000}, + // Show events + {action: () => setActiveTab('events' as TabId), delay: 2200}, + // Go to docs + {action: () => setActiveTab('docs' as TabId), delay: 2000}, + {action: () => setSelectedDocIndex(1), delay: 1600}, + {action: () => setSelectedDocIndex(2), delay: 1200}, + {action: () => setSelectedDocIndex(0), delay: 1200}, + // Reset data, back to dashboard + { + action: () => { + resetTerminal() + setActiveTab('dashboard' as TabId) + }, + delay: 2200 + } + ] + + let currentIndex = 0 + + const runNext = () => { + if (!isAutoPlaying) { + return + } + if (currentIndex >= sequence.length) { + currentIndex = 0 + } + const item = sequence[currentIndex] + if (item) { + autoPlayRef.current = setTimeout(() => { + item.action() + currentIndex++ + runNext() + }, item.delay) + } + } + + runNext() + + return () => { + if (autoPlayRef.current) { + clearTimeout(autoPlayRef.current) + } + } + }, [ + ingestDocument, + isAutoPlaying, + resetTerminal, + setActiveTab, + setSelectedDocIndex + ]) + + return ( + + + + + + + + ) +} + +function TerminalInner({ + autoPlay = false, + className, + onTitleBarPointerDown, + style, + resizable +}: TerminalProps) { + const [isTyping, setIsTyping] = useState(autoPlay) + const [typedCommand, setTypedCommand] = useState(autoPlay ? '' : COMMAND) + const [showTUI, setShowTUI] = useState(!autoPlay) + + useEffect(() => { + if (!autoPlay || !isTyping) { + return + } + + let charIndex = 0 + const typeInterval = setInterval(() => { + if (charIndex < COMMAND.length) { + setTypedCommand(COMMAND.slice(0, charIndex + 1)) + charIndex++ + } else { + clearInterval(typeInterval) + setIsTyping(false) + setTimeout(() => { + setShowTUI(true) + }, TUI_REVEAL_DELAY) + } + }, TYPING_SPEED) + + return () => { + clearInterval(typeInterval) + } + }, [autoPlay, isTyping]) + + const handleInteraction = useCallback(() => {}, []) + + return ( +
+ {/* macOS-style title bar - always visible */} + + + {/* Command line - always visible once typing starts */} + + + {/* TUI content - appears after typing completes */} + + {showTUI && } + +
+ ) +} + +export function Terminal({ + className, + autoPlay = false, + initialTab = 'dashboard', + onTitleBarPointerDown, + style, + resizable +}: TerminalProps) { + return ( + + + + ) +} diff --git a/apps/web/package.json b/apps/web/package.json index 2d55965..88a9c5f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,8 @@ "lint": "biome check ." }, "dependencies": { + "@mantine/hooks": "^8.3.13", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.2", @@ -19,11 +21,10 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-tabs": "^1.1.13", - "@phosphor-icons/react": "^2.1.10", "@ridemountainpig/svgl-react": "^1.0.14", "@tanstack/react-table": "^8.21.3", - "@vercel/analytics": "^1.6.1", "@upstash/redis": "^1.35.3", + "@vercel/analytics": "^1.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fumadocs-core": "16.2.5", diff --git a/apps/web/public/unrag.svg b/apps/web/public/unrag.svg new file mode 100644 index 0000000..a910fc2 --- /dev/null +++ b/apps/web/public/unrag.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bun.lock b/bun.lock index f526195..ef1b443 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@mantine/hooks": "^8.3.13", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -100,7 +101,7 @@ }, "packages/unrag": { "name": "unrag", - "version": "0.2.12", + "version": "0.3.2", "bin": { "unrag": "./dist/cli/index.js", }, @@ -457,6 +458,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mantine/hooks": ["@mantine/hooks@8.3.13", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], diff --git a/packages/unrag/package.json b/packages/unrag/package.json index 043f96f..882fe60 100644 --- a/packages/unrag/package.json +++ b/packages/unrag/package.json @@ -47,11 +47,7 @@ "unrag:doctor:db": "unrag doctor --config .unrag/doctor.json --db", "unrag:doctor:ci": "unrag doctor --config .unrag/doctor.json --db --strict --json" }, - "files": [ - "dist/**", - "registry/**", - "README.md" - ], + "files": ["dist/**", "registry/**", "README.md"], "publishConfig": { "access": "public" }