+
+ 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
+
+
+
+ )
+}
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 (
+
+
+
+ )
+}
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 */}
+
+
+ {/* 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"
}