From e5cc84f0ac6fb8ca2673c548dda01dd79f8e4b2a Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 00:49:32 +0530 Subject: [PATCH 01/11] feat: add new terminal component including dashboard, tab bar, and status bar. --- apps/web/components/home/hero.tsx | 27 ++- apps/web/components/terminal/index.ts | 2 + .../components/terminal/terminal-content.tsx | 39 ++++ .../components/terminal/terminal-context.tsx | 82 ++++++++ .../terminal/terminal-dashboard.tsx | 141 +++++++++++++ .../web/components/terminal/terminal-docs.tsx | 170 ++++++++++++++++ .../components/terminal/terminal-header.tsx | 30 +++ .../web/components/terminal/terminal-logo.tsx | 11 + .../components/terminal/terminal-mock-data.ts | 170 ++++++++++++++++ .../terminal/terminal-status-bar.tsx | 29 +++ .../components/terminal/terminal-tab-bar.tsx | 38 ++++ .../web/components/terminal/terminal-theme.ts | 67 ++++++ .../web/components/terminal/terminal-types.ts | 65 ++++++ apps/web/components/terminal/terminal.tsx | 192 ++++++++++++++++++ apps/web/public/unrag.svg | 48 +++++ bun.lock | 3 +- packages/unrag/package.json | 6 +- 17 files changed, 1105 insertions(+), 15 deletions(-) create mode 100644 apps/web/components/terminal/index.ts create mode 100644 apps/web/components/terminal/terminal-content.tsx create mode 100644 apps/web/components/terminal/terminal-context.tsx create mode 100644 apps/web/components/terminal/terminal-dashboard.tsx create mode 100644 apps/web/components/terminal/terminal-docs.tsx create mode 100644 apps/web/components/terminal/terminal-header.tsx create mode 100644 apps/web/components/terminal/terminal-logo.tsx create mode 100644 apps/web/components/terminal/terminal-mock-data.ts create mode 100644 apps/web/components/terminal/terminal-status-bar.tsx create mode 100644 apps/web/components/terminal/terminal-tab-bar.tsx create mode 100644 apps/web/components/terminal/terminal-theme.ts create mode 100644 apps/web/components/terminal/terminal-types.ts create mode 100644 apps/web/components/terminal/terminal.tsx create mode 100644 apps/web/public/unrag.svg diff --git a/apps/web/components/home/hero.tsx b/apps/web/components/home/hero.tsx index 93f3ad1..fc05488 100644 --- a/apps/web/components/home/hero.tsx +++ b/apps/web/components/home/hero.tsx @@ -11,6 +11,7 @@ import { Text } from '../elements' import {ArrowNarrowRightIcon} from '../icons' +import {Terminal} from '../terminal' function HeroLeftAlignedWithDemo({ eyebrow, @@ -54,8 +55,8 @@ function HeroLeftAlignedWithDemo({ function HeroImageFrame({ className, - imageClassName -}: {className?: string; imageClassName?: string}) { + children +}: {className?: string; children?: ReactNode}) { return (
- Unrag product interface + {children}
@@ -128,8 +125,20 @@ export function HeroSection() { } demo={ <> - - + + + + + + } footer={ diff --git a/apps/web/components/terminal/index.ts b/apps/web/components/terminal/index.ts new file mode 100644 index 0000000..422e972 --- /dev/null +++ b/apps/web/components/terminal/index.ts @@ -0,0 +1,2 @@ +export {Terminal} from './terminal' +export type {TerminalProps, TabId} from './terminal-types' diff --git a/apps/web/components/terminal/terminal-content.tsx b/apps/web/components/terminal/terminal-content.tsx new file mode 100644 index 0000000..54d122f --- /dev/null +++ b/apps/web/components/terminal/terminal-content.tsx @@ -0,0 +1,39 @@ +'use client' + +import {AnimatePresence, motion} from 'motion/react' +import {useTerminal} from './terminal-context' +import {TerminalDashboard} from './terminal-dashboard' +import {TerminalDocs} from './terminal-docs' + +export function TerminalContent() { + const {activeTab} = useTerminal() + + return ( +
+ + + {activeTab === 'docs' && } + {activeTab === 'dashboard' && } + {activeTab !== 'docs' && activeTab !== 'dashboard' && ( + + )} + + +
+ ) +} + +function TerminalPlaceholder({tab}: {tab: string}) { + return ( +
+ {tab.toUpperCase()} view +
+ ) +} diff --git a/apps/web/components/terminal/terminal-context.tsx b/apps/web/components/terminal/terminal-context.tsx new file mode 100644 index 0000000..3f2f872 --- /dev/null +++ b/apps/web/components/terminal/terminal-context.tsx @@ -0,0 +1,82 @@ +'use client' + +import { + type ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useState +} from 'react' +import type {TabId, TerminalContextValue} from './terminal-types' + +const TerminalContext = createContext(null) + +export function useTerminal() { + const context = useContext(TerminalContext) + if (!context) { + throw new Error('useTerminal must be used within a TerminalProvider') + } + return context +} + +interface TerminalProviderProps { + children: ReactNode + initialTab?: TabId +} + +export function TerminalProvider({ + children, + initialTab = 'docs' +}: TerminalProviderProps) { + const [activeTab, setActiveTab] = useState(initialTab) + const [selectedDocIndex, setSelectedDocIndex] = useState(0) + const [selectedChunkIndex, setSelectedChunkIndex] = useState(0) + const [isAnimating, setIsAnimating] = useState(false) + + const handleSetActiveTab = useCallback((tab: TabId) => { + setActiveTab(tab) + }, []) + + const handleSetSelectedDocIndex = useCallback((index: number) => { + setSelectedDocIndex(index) + setSelectedChunkIndex(0) + }, []) + + const handleSetSelectedChunkIndex = useCallback((index: number) => { + setSelectedChunkIndex(index) + }, []) + + const handleSetIsAnimating = useCallback((animating: boolean) => { + setIsAnimating(animating) + }, []) + + const value = useMemo( + () => ({ + activeTab, + selectedDocIndex, + selectedChunkIndex, + isAnimating, + setActiveTab: handleSetActiveTab, + setSelectedDocIndex: handleSetSelectedDocIndex, + setSelectedChunkIndex: handleSetSelectedChunkIndex, + setIsAnimating: handleSetIsAnimating + }), + [ + activeTab, + selectedDocIndex, + selectedChunkIndex, + isAnimating, + handleSetActiveTab, + handleSetSelectedDocIndex, + handleSetSelectedChunkIndex, + handleSetIsAnimating + ] + ) + + return ( + + {children} + + ) +} diff --git a/apps/web/components/terminal/terminal-dashboard.tsx b/apps/web/components/terminal/terminal-dashboard.tsx new file mode 100644 index 0000000..8c0d3fc --- /dev/null +++ b/apps/web/components/terminal/terminal-dashboard.tsx @@ -0,0 +1,141 @@ +'use client' +import {chars, theme} from './terminal-theme' + +const mockOperations = [ + {title: 'INGEST', count: 42, lastMs: 156, avgMs: 142}, + {title: 'RETRIEVE', count: 128, lastMs: 23, avgMs: 31}, + {title: 'RERANK', count: 89, lastMs: 45, avgMs: 52}, + {title: 'DELETE', count: 3, lastMs: 12, avgMs: 15} +] + +const mockLatencyHistory = [ + 42, 38, 45, 52, 48, 35, 41, 39, 56, 62, 48, 44, 38, 42, 51, 47, 39, 36, 44, + 48 +] + +const mockRecentEvents = [ + {type: 'retrieve:complete', time: '14:23:45', duration: '23ms'}, + {type: 'ingest:complete', time: '14:23:42', duration: '156ms'}, + {type: 'retrieve:complete', time: '14:23:38', duration: '31ms'}, + {type: 'rerank:complete', time: '14:23:35', duration: '45ms'}, + {type: 'retrieve:complete', time: '14:23:30', duration: '28ms'} +] + +function SectionHeader({title}: {title: string}) { + return ( +
+ + {chars.section} {title.toUpperCase()} + +
+ ) +} + +function MetricCard({ + title, + count, + lastMs, + avgMs +}: { + title: string + count: number + lastMs: number + avgMs: number +}) { + return ( +
+
{title}
+
+ {count} +
+
+ {lastMs}ms last {chars.dot} {avgMs}ms avg +
+
+ ) +} + +function Sparkline({data}: {data: number[]}) { + const max = Math.max(...data) + const min = Math.min(...data) + const range = max - min || 1 + + return ( +
+ {data.map((value, idx) => { + const height = ((value - min) / range) * 100 + return ( +
+ ) + })} +
+ ) +} + +function EventRow({ + type, + time, + duration +}: { + type: string + time: string + duration: string +}) { + const isComplete = type.includes('complete') + return ( +
+ + {isComplete ? chars.check : chars.cross} + + {time} + {type} + {duration} +
+ ) +} + +export function TerminalDashboard() { + return ( +
+ {/* Stats row */} +
+ +
+ {mockOperations.map((op) => ( + + ))} +
+
+ + {/* Latency sparkline */} +
+ + +
+ + {/* Recent events */} +
+ +
+ {mockRecentEvents.map((event) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-docs.tsx b/apps/web/components/terminal/terminal-docs.tsx new file mode 100644 index 0000000..46466c7 --- /dev/null +++ b/apps/web/components/terminal/terminal-docs.tsx @@ -0,0 +1,170 @@ +'use client' + +import {clsx} from 'clsx/lite' +import {motion} from 'motion/react' +import {useTerminal} from './terminal-context' +import { + mockChunkDetails, + mockChunkRanges, + mockDocuments +} from './terminal-mock-data' +import {chars, theme} from './terminal-theme' + +function DocumentList() { + const {selectedDocIndex, setSelectedDocIndex} = useTerminal() + + return ( +
+
+ Documents ({mockDocuments.length}) +
+
+ {mockDocuments.slice(0, 12).map((doc, idx) => { + const isSelected = idx === selectedDocIndex + return ( + setSelectedDocIndex(idx)} + className={clsx( + 'w-full text-left px-3 py-1 text-[10px] flex items-center justify-between transition-colors duration-150', + isSelected + ? 'text-black' + : 'text-white/70 hover:bg-white/5' + )} + style={ + isSelected + ? {backgroundColor: theme.accent} + : undefined + } + initial={false} + animate={{ + backgroundColor: isSelected + ? theme.accent + : 'transparent' + }} + transition={{duration: 0.15}} + > + + {chars.arrow} {doc.sourceId} + + + {doc.chunks} + + + ) + })} +
+
+ ) +} + +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-header.tsx b/apps/web/components/terminal/terminal-header.tsx new file mode 100644 index 0000000..e7a2e4e --- /dev/null +++ b/apps/web/components/terminal/terminal-header.tsx @@ -0,0 +1,30 @@ +'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-logo.tsx b/apps/web/components/terminal/terminal-logo.tsx new file mode 100644 index 0000000..8be1ef6 --- /dev/null +++ b/apps/web/components/terminal/terminal-logo.tsx @@ -0,0 +1,11 @@ +'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..328a04a --- /dev/null +++ b/apps/web/components/terminal/terminal-mock-data.ts @@ -0,0 +1,170 @@ +/** + * 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...' + } + ] +} + +/** + * Logo pixel art data from /packages/unrag/registry/debug/tui/assets/unragLogo.ts + * 'g' = filled pixel (rendered as block), 'u' = empty pixel (whitespace) + */ +export const UNRAG_LOGO_LINES: string[] = [ + 'gggggggggguuuuuugggggggggguuuuugggggggguuuuuuuuggggggggguuuuuuggggggggggggggggguuuuuuuuuuuuuuuuuuuuuggggggggggguuuuuuuuuuuuuuuuuuuggggggggggggguuuuuuu', + 'gggggggggguuuuuugggggggggguuuuuggggggggguuuuuuuggggggggguuuuuugggggggggggggggggggguuuuuuuuuuuuuuuuuggggggggggggguuuuuuuuuuuuuuuuggggggggggggggggguuuuu', + 'gggggggggguuuuuugggggggggguuuuugggggggggguuuuuuggggggggguuuuuugggggggggggggggggggggguuuuuuuuuuuuuuuggggggggggggguuuuuuuuuuuuuugggggggggggggggggggguuuu', + 'gggggggggguuuuuugggggggggguuuuuggggggggggguuuuuggggggggguuuuuuggggggggggggggggggggggguuuuuuuuuuuuugggggggggggggguuuuuuuuuuuuuggggggggggggggggggggggguu', + 'gggggggggguuuuuuggggguuuuuuuuuuggggggggggguuuuuggggggggguuuuuugggggggggggggggggggggggguuuuuuuuuuuuggggggggggggggguuuuuuuuuuugggggggggggggggggggggggggu', + 'gggggggggguuuuuuuuuuuuuuuuuuuuugggggggggggguuuuggggggggguuuuuuggggggggggggggggggggggggguuuuuuuuuugggggggggggggggguuuuuuuuuuggggggggggggguugggggggggggu', + 'ggggggggguuuuuuuuuuuuuuggguuuuuggggggggggggguuuggggggggguuuuuugggggggggguuuuggggggggggguuuuuuuuuuggggggggggggggggguuuuuuuuuggggggggggguuuuuggggggggggg', + 'gggggguuuuuuuuuuuggggggggguuuuugggggggggggggguuggggggggguuuuuugggggggggguuuuugggggggggguuuuuuuuuuggggggggggggggggguuuuuuuuugggggggggguuuuuuugggggggggg', + 'uuuuuuuuuuuuuuuugggggggggguuuuugggggggggggggguuggggggggguuuuuugggggggggguuuuugggggggggguuuuuuuuuggggggggggggggggggguuuuuuugggggggggguuuuuuuugggggggggg', + 'uuuuuuuggguuuuuugggggggggguuuuuggggggggggggggguggggggggguuuuuugggggggggguuuugggggggggguuuuuuuuuuggggggggguggggggggguuuuuuugggggggggguuuuuuuuuuuuuuuuuu', + 'uugggggggguuuuuugggggggggguuuuuggggggggggggggggggggggggguuuuuugggggggggggggggggggggggguuuuuuuuugggggggggguggggggggguuuuuuugggggggggguuuugggggggggggggg', + 'gggggggggguuuuuugggggggggguuuuuggggggggggggggggggggggggguuuuuugggggggggggggggggggggguuuuuuuuuuuggggggggguugggggggggguuuuuugggggggggguuuugggggggggggggg', + 'gggggggggguuuuuugggggggggguuuuuggggggggggggggggggggggggguuuuuugggggggggggggggggggguuuuuuuuuuuugggggggggguuuggggggggguuuuuugggggggggguuuugggggggggggggg', + 'gggggggggguuuuuugggggggggguuuuuggggggggguggggggggggggggguuuuuuggggggggggggggggggggggguuuuuuuuuggggggggguuuugggggggggguuuuugggggggggguuuugggggggggggggg', + 'gggggggggguuuuuugggggggggguuuuuggggggggguugggggggggggggguuuuuugggggggggggggggggggggggguuuuuuuuggggggggggggggggggggggguuuuugggggggggguuuugggggggggggggg', + 'gggggggggguuuuuugggggggggguuuuuggggggggguuuggggggggggggguuuuuuggggggggggugggggggggggggguuuuuugggggggggggggggggggggggguuuuuggggggggggguuuuuuuuggggggggg', + 'gggggggggguuuuuugggggggggguuuuuggggggggguuuggggggggggggguuuuuugggggggggguuuuugggggggggguuuuuuggggggggggggggggggggggggguuuuuggggggggggguuuuuugggggggggg', + 'ggggggggggguuuugggggggggguuuuuuggggggggguuuugggggggggggguuuuuugggggggggguuuuugggggggggguuuuugggggggggggggggggggggggggguuuuuggggggggggggguuuggggggggggg', + 'ggggggggggggggggggggggggguuuuuuggggggggguuuuuggggggggggguuuuuugggggggggguuuuugggggggggguuuuuggggggggggggggggggggggggggguuuuugggggggggggggggggggggggggg', + 'uggggggggggggggggggggggguuuuuuuggggggggguuuuuugggggggggguuuuuugggggggggguuuuugggggggggguuuuuggggggggguuuuuuuugggggggggguuuuuuggggggggggggggggggggggggg', + 'uuggggggggggggggggggggguuuuuuuuggggggggguuuuuugggggggggguuuuuugggggggggguuuuuugggggggggguuugggggggggguuuuuuuuugggggggggguuuuuugggggggggggggggggggggggg', + 'uuuugggggggggggggggggguuuuuuuuuggggggggguuuuuuuggggggggguuuuuugggggggggguuuuuugggggggggguuuggggggggguuuuuuuuuugggggggggguuuuuuuugggggggggggggggugggggg', + 'uuuuuuugggggggggggguuuuuuuuuuuuggggggggguuuuuuuugggggggguuuuuuggggggggguuuuuuugggggggggguugggggggggguuuuuuuuuugggggggggguuuuuuuuuuuggggggggggguugggggg' +] + +export const mockSessionId = 'cb1b711d-8f2e-4a5c-b9d3-2e8f1a3c4b5d' 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..237f50a --- /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-7', 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..16b5581 --- /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..0ab8956 --- /dev/null +++ b/apps/web/components/terminal/terminal-theme.ts @@ -0,0 +1,67 @@ +/** + * 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: '#1A1A1A', + 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: 'doctor', label: 'Doctor', shortcut: 6}, + {id: 'eval', label: 'Eval', shortcut: 7} +] as const diff --git a/apps/web/components/terminal/terminal-types.ts b/apps/web/components/terminal/terminal-types.ts new file mode 100644 index 0000000..22fac6e --- /dev/null +++ b/apps/web/components/terminal/terminal-types.ts @@ -0,0 +1,65 @@ +/** + * TypeScript interfaces for the Terminal component. + */ + +export type TabId = + | 'dashboard' + | 'events' + | 'traces' + | 'query' + | 'docs' + | 'doctor' + | 'eval' + +export interface Tab { + id: TabId + label: string + shortcut: number +} + +export interface TerminalState { + activeTab: TabId + selectedDocIndex: number + selectedChunkIndex: number + isAnimating: boolean +} + +export interface TerminalActions { + setActiveTab: (tab: TabId) => void + setSelectedDocIndex: (index: number) => void + setSelectedChunkIndex: (index: number) => void + setIsAnimating: (animating: boolean) => 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 +} diff --git a/apps/web/components/terminal/terminal.tsx b/apps/web/components/terminal/terminal.tsx new file mode 100644 index 0000000..108be67 --- /dev/null +++ b/apps/web/components/terminal/terminal.tsx @@ -0,0 +1,192 @@ +'use client' + +import {clsx} from 'clsx/lite' +import {AnimatePresence, motion} from 'motion/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 type {TabId, TerminalProps} from './terminal-types' + +const COMMAND = 'bunx unrag debug' +const TYPING_SPEED = 80 +const COMMAND_DELAY = 500 +const TUI_REVEAL_DELAY = 400 + +function TerminalPrompt({ + command, + isTyping +}: { + command: string + isTyping: boolean +}) { + return ( +
+ $ + {command} + {isTyping && ( + + )} +
+ ) +} + +function TerminalTUI({onInteraction}: {onInteraction: () => void}) { + const {setSelectedDocIndex, setActiveTab} = 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) + onInteraction() + }, [onInteraction]) + + useEffect(() => { + if (!isAutoPlaying) { + return + } + + const sequence: Array<{action: () => void; delay: number}> = [ + {action: () => setSelectedDocIndex(1), delay: 2500}, + {action: () => setSelectedDocIndex(2), delay: 1800}, + {action: () => setSelectedDocIndex(0), delay: 1800}, + {action: () => setActiveTab('dashboard' as TabId), delay: 3000}, + {action: () => setActiveTab('docs' as TabId), delay: 2500} + ] + + 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) + } + } + }, [isAutoPlaying, setSelectedDocIndex, setActiveTab]) + + return ( + + + + + + + + ) +} + +function TerminalInner({autoPlay, className}: TerminalProps) { + const [phase, setPhase] = useState<'typing' | 'tui'>( + autoPlay ? 'typing' : 'tui' + ) + const [typedCommand, setTypedCommand] = useState('') + const [_hasInteracted, setHasInteracted] = useState(false) + + useEffect(() => { + if (!autoPlay || phase !== 'typing') { + return + } + + let charIndex = 0 + const typeInterval = setInterval(() => { + if (charIndex < COMMAND.length) { + setTypedCommand(COMMAND.slice(0, charIndex + 1)) + charIndex++ + } else { + clearInterval(typeInterval) + setTimeout(() => { + setPhase('tui') + }, TUI_REVEAL_DELAY) + } + }, TYPING_SPEED) + + const initialDelay = setTimeout(() => {}, COMMAND_DELAY) + + return () => { + clearInterval(typeInterval) + clearTimeout(initialDelay) + } + }, [autoPlay, phase]) + + const handleInteraction = useCallback(() => { + setHasInteracted(true) + }, []) + + return ( +
+ + {phase === 'typing' ? ( + + + + ) : ( + + )} + +
+ ) +} + +export function Terminal({ + className, + autoPlay = false, + initialTab = 'docs' +}: TerminalProps) { + return ( + + + + ) +} 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..458efeb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "unrag", @@ -100,7 +101,7 @@ }, "packages/unrag": { "name": "unrag", - "version": "0.2.12", + "version": "0.3.2", "bin": { "unrag": "./dist/cli/index.js", }, 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" } From e3bdfb1ff7d868f8f1d2dfc476be1f6de6069f36 Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 02:21:29 +0530 Subject: [PATCH 02/11] refactor: Refactor terminal animation logic, increase terminal content height, and update prompt styling. --- .../components/terminal/terminal-content.tsx | 2 +- apps/web/components/terminal/terminal.tsx | 61 +++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/web/components/terminal/terminal-content.tsx b/apps/web/components/terminal/terminal-content.tsx index 54d122f..9cd00dd 100644 --- a/apps/web/components/terminal/terminal-content.tsx +++ b/apps/web/components/terminal/terminal-content.tsx @@ -9,7 +9,7 @@ export function TerminalContent() { const {activeTab} = useTerminal() return ( -
+
- $ +
+ vercel + $ {command} - {isTyping && ( + {isTyping && showCursor && ( void}) { } function TerminalInner({autoPlay, className}: TerminalProps) { - const [phase, setPhase] = useState<'typing' | 'tui'>( - autoPlay ? 'typing' : 'tui' - ) - const [typedCommand, setTypedCommand] = useState('') - const [_hasInteracted, setHasInteracted] = useState(false) + const [isTyping, setIsTyping] = useState(autoPlay) + const [typedCommand, setTypedCommand] = useState(autoPlay ? '' : COMMAND) + const [showTUI, setShowTUI] = useState(!autoPlay) + const [hasInteracted, setHasInteracted] = useState(false) useEffect(() => { - if (!autoPlay || phase !== 'typing') { - return - } + if (!autoPlay || !isTyping) return let charIndex = 0 const typeInterval = setInterval(() => { @@ -134,19 +134,17 @@ function TerminalInner({autoPlay, className}: TerminalProps) { charIndex++ } else { clearInterval(typeInterval) + setIsTyping(false) setTimeout(() => { - setPhase('tui') + setShowTUI(true) }, TUI_REVEAL_DELAY) } }, TYPING_SPEED) - const initialDelay = setTimeout(() => {}, COMMAND_DELAY) - return () => { clearInterval(typeInterval) - clearTimeout(initialDelay) } - }, [autoPlay, phase]) + }, [autoPlay, isTyping]) const handleInteraction = useCallback(() => { setHasInteracted(true) @@ -155,24 +153,23 @@ function TerminalInner({autoPlay, className}: TerminalProps) { return (
- - {phase === 'typing' ? ( - - - - ) : ( - + {/* Command line - always visible once typing starts */} + + + {/* TUI content - appears after typing completes */} + + {showTUI && ( + )}
From bbf3761e1761eefea26c709c7c3e31b12170def4 Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 02:37:25 +0530 Subject: [PATCH 03/11] feat: Add a macOS-style terminal title bar and refine animation and typing speeds. --- apps/web/components/terminal/terminal.tsx | 29 ++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/web/components/terminal/terminal.tsx b/apps/web/components/terminal/terminal.tsx index a477fff..b75d345 100644 --- a/apps/web/components/terminal/terminal.tsx +++ b/apps/web/components/terminal/terminal.tsx @@ -12,9 +12,23 @@ import {TerminalTabBar} from './terminal-tab-bar' import type {TabId, TerminalProps} from './terminal-types' const COMMAND = 'bunx unrag debug' -const TYPING_SPEED = 80 -const COMMAND_DELAY = 500 -const TUI_REVEAL_DELAY = 400 +const TYPING_SPEED = 50 +const TUI_REVEAL_DELAY = 300 + +function TerminalTitleBar() { + return ( +
+
+ + + +
+ + Terminal + +
+ ) +} function TerminalPrompt({ command, @@ -101,9 +115,9 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { return ( + {/* macOS-style title bar - always visible */} + + {/* Command line - always visible once typing starts */} Date: Tue, 20 Jan 2026 11:30:39 +0530 Subject: [PATCH 04/11] feat: Introduce query execution, event logging, and trace visualization capabilities to the terminal. --- .../components/terminal/terminal-content.tsx | 16 +- .../components/terminal/terminal-context.tsx | 152 +++++++++- .../terminal/terminal-dashboard.tsx | 5 +- .../web/components/terminal/terminal-docs.tsx | 37 +-- .../components/terminal/terminal-events.tsx | 262 ++++++++++++++++++ .../components/terminal/terminal-header.tsx | 6 +- .../components/terminal/terminal-mock-data.ts | 30 -- .../components/terminal/terminal-query.tsx | 216 +++++++++++++++ .../components/terminal/terminal-tab-bar.tsx | 6 +- .../components/terminal/terminal-traces.tsx | 189 +++++++++++++ .../web/components/terminal/terminal-types.ts | 43 +++ apps/web/components/terminal/terminal.tsx | 40 ++- 12 files changed, 922 insertions(+), 80 deletions(-) create mode 100644 apps/web/components/terminal/terminal-events.tsx create mode 100644 apps/web/components/terminal/terminal-query.tsx create mode 100644 apps/web/components/terminal/terminal-traces.tsx diff --git a/apps/web/components/terminal/terminal-content.tsx b/apps/web/components/terminal/terminal-content.tsx index 9cd00dd..ba6833c 100644 --- a/apps/web/components/terminal/terminal-content.tsx +++ b/apps/web/components/terminal/terminal-content.tsx @@ -4,6 +4,9 @@ import {AnimatePresence, motion} from 'motion/react' import {useTerminal} from './terminal-context' import {TerminalDashboard} from './terminal-dashboard' import {TerminalDocs} from './terminal-docs' +import {TerminalEvents} from './terminal-events' +import {TerminalQuery} from './terminal-query' +import {TerminalTraces} from './terminal-traces' export function TerminalContent() { const {activeTab} = useTerminal() @@ -19,11 +22,16 @@ export function TerminalContent() { transition={{duration: 0.15}} className="h-full" > - {activeTab === 'docs' && } {activeTab === 'dashboard' && } - {activeTab !== 'docs' && activeTab !== 'dashboard' && ( - - )} + {activeTab === 'events' && } + {activeTab === 'traces' && } + {activeTab === 'query' && } + {activeTab === 'docs' && } + {activeTab !== 'dashboard' && + activeTab !== 'events' && + activeTab !== 'traces' && + activeTab !== 'query' && + activeTab !== 'docs' && }
diff --git a/apps/web/components/terminal/terminal-context.tsx b/apps/web/components/terminal/terminal-context.tsx index 3f2f872..fbd10f4 100644 --- a/apps/web/components/terminal/terminal-context.tsx +++ b/apps/web/components/terminal/terminal-context.tsx @@ -8,7 +8,14 @@ import { useMemo, useState } from 'react' -import type {TabId, TerminalContextValue} from './terminal-types' +import {theme} from './terminal-theme' +import type { + QueryResult, + TabId, + TerminalContextValue, + TerminalEvent, + TerminalTrace +} from './terminal-types' const TerminalContext = createContext(null) @@ -25,14 +32,60 @@ interface TerminalProviderProps { initialTab?: TabId } +// Mock results that simulate real retrieval +const mockResultsPool: QueryResult[] = [ + { + id: '1', + sourceId: 'blog:resend:how-to-send-emails-using-bun', + score: 0.89, + content: + 'Unrag ingest works by chunking documents into smaller pieces, generating embeddings for each chunk...' + }, + { + id: '2', + sourceId: 'docs:unrag:getting-started', + score: 0.85, + content: + 'The ingest process involves three main steps: document parsing, chunking, and embedding generation...' + }, + { + id: '3', + sourceId: 'docs:unrag:api-reference', + score: 0.82, + content: + 'Use the ingest() function to add documents to your vector store. It accepts a source identifier...' + }, + { + id: '4', + sourceId: 'blog:resend:improving-time-to-inbox', + score: 0.78, + content: + 'The chunking strategy can be customized through the chunkSize and overlap parameters...' + } +] + +function generateId() { + return Math.random().toString(36).substring(2, 15) +} + +function getCurrentTime() { + const now = new Date() + return now.toTimeString().slice(0, 8) +} + export function TerminalProvider({ children, - initialTab = 'docs' + initialTab = 'dashboard' }: TerminalProviderProps) { const [activeTab, setActiveTab] = useState(initialTab) const [selectedDocIndex, setSelectedDocIndex] = useState(0) const [selectedChunkIndex, setSelectedChunkIndex] = useState(0) const [isAnimating, setIsAnimating] = useState(false) + const [events, setEvents] = useState([]) + const [traces, setTraces] = useState([]) + const [queryResults, setQueryResults] = useState([]) + const [lastQuery, setLastQuery] = useState('') + const [hasUserInteracted, setHasUserInteracted] = useState(false) const handleSetActiveTab = useCallback((tab: TabId) => { setActiveTab(tab) @@ -51,26 +104,117 @@ export function TerminalProvider({ setIsAnimating(animating) }, []) + const stopAllAnimations = useCallback(() => { + setHasUserInteracted(true) + }, []) + + const runQuery = useCallback((query: string) => { + if (!query.trim()) return + + const traceId = generateId() + const startTime = getCurrentTime() + + // Simulate timing values + const embedMs = 200 + Math.floor(Math.random() * 300) + const dbMs = 300 + Math.floor(Math.random() * 500) + const totalMs = embedMs + dbMs + + // Create events for this query + const newEvents: TerminalEvent[] = [ + { + id: generateId(), + type: 'retrieve:complete', + time: startTime, + duration: `${totalMs}ms`, + results: '8/8', + query: query, + embed: `${embedMs}ms`, + db: `${(dbMs / 1000).toFixed(1)}s`, + total: `${(totalMs / 1000).toFixed(1)}s` + }, + { + id: generateId(), + type: 'retrieve:database-complete', + time: startTime, + duration: `${dbMs}ms`, + results: '8 results' + }, + { + id: generateId(), + type: 'retrieve:embedding-complete', + time: startTime, + duration: `${embedMs}ms`, + results: 'dim=1536' + }, + { + id: generateId(), + type: 'retrieve:start', + time: startTime, + duration: '', + query: `"${query.slice(0, 25)}${query.length > 25 ? '...' : ''}" k=8` + } + ] + + // Create trace for this query + const newTrace: TerminalTrace = { + id: traceId, + opName: 'RETRIEVE', + time: startTime, + label: `q ${query.slice(0, 30)}${query.length > 30 ? '...' : ''} [${(totalMs / 1000).toFixed(1)}s]`, + totalMs: totalMs.toString(), + stages: [ + {name: 'EMBEDDING', ms: embedMs, color: theme.accent}, + {name: 'DB', ms: dbMs, color: '#ffffff'} + ], + events: [ + {time: startTime, type: 'retrieve:start'}, + {time: startTime, type: 'retrieve:embedding-complete'}, + {time: startTime, type: 'retrieve:database-complete'}, + {time: startTime, type: 'retrieve:complete'} + ] + } + + // Update state + setLastQuery(query) + setQueryResults(mockResultsPool) + setEvents((prev) => [...newEvents, ...prev]) + setTraces((prev) => [newTrace, ...prev]) + }, []) + const value = useMemo( () => ({ activeTab, selectedDocIndex, selectedChunkIndex, isAnimating, + events, + traces, + queryResults, + lastQuery, + hasUserInteracted, setActiveTab: handleSetActiveTab, setSelectedDocIndex: handleSetSelectedDocIndex, setSelectedChunkIndex: handleSetSelectedChunkIndex, - setIsAnimating: handleSetIsAnimating + setIsAnimating: handleSetIsAnimating, + runQuery, + stopAllAnimations }), [ activeTab, selectedDocIndex, selectedChunkIndex, isAnimating, + events, + traces, + queryResults, + lastQuery, + hasUserInteracted, handleSetActiveTab, handleSetSelectedDocIndex, handleSetSelectedChunkIndex, - handleSetIsAnimating + handleSetIsAnimating, + runQuery, + stopAllAnimations ] ) diff --git a/apps/web/components/terminal/terminal-dashboard.tsx b/apps/web/components/terminal/terminal-dashboard.tsx index 8c0d3fc..8b3df1a 100644 --- a/apps/web/components/terminal/terminal-dashboard.tsx +++ b/apps/web/components/terminal/terminal-dashboard.tsx @@ -24,10 +24,7 @@ const mockRecentEvents = [ function SectionHeader({title}: {title: string}) { return (
- + {chars.section} {title.toUpperCase()}
diff --git a/apps/web/components/terminal/terminal-docs.tsx b/apps/web/components/terminal/terminal-docs.tsx index 46466c7..a7e31a3 100644 --- a/apps/web/components/terminal/terminal-docs.tsx +++ b/apps/web/components/terminal/terminal-docs.tsx @@ -1,7 +1,6 @@ 'use client' import {clsx} from 'clsx/lite' -import {motion} from 'motion/react' import {useTerminal} from './terminal-context' import { mockChunkDetails, @@ -22,28 +21,16 @@ function DocumentList() { {mockDocuments.slice(0, 12).map((doc, idx) => { const isSelected = idx === selectedDocIndex return ( - setSelectedDocIndex(idx)} className={clsx( 'w-full text-left px-3 py-1 text-[10px] flex items-center justify-between transition-colors duration-150', isSelected - ? 'text-black' - : 'text-white/70 hover:bg-white/5' + ? 'text-white font-medium' + : 'text-white/50 hover:text-white/70' )} - style={ - isSelected - ? {backgroundColor: theme.accent} - : undefined - } - initial={false} - animate={{ - backgroundColor: isSelected - ? theme.accent - : 'transparent' - }} - transition={{duration: 0.15}} > {chars.arrow} {doc.sourceId} @@ -51,14 +38,12 @@ function DocumentList() { {doc.chunks} - + ) })}
@@ -132,11 +117,19 @@ function ChunkDetails() { onClick={() => setSelectedChunkIndex(chunk.idx)} className={clsx( 'w-full text-left px-3 py-1.5 text-[10px] border-b border-white/5 transition-colors duration-150', - isSelected ? 'bg-white/10' : 'hover:bg-white/5' + isSelected ? 'bg-white/5' : 'hover:bg-white/5' )} >
- #{chunk.idx} + + #{chunk.idx} + {chunk.tokens} tokens diff --git a/apps/web/components/terminal/terminal-events.tsx b/apps/web/components/terminal/terminal-events.tsx new file mode 100644 index 0000000..bc2d97d --- /dev/null +++ b/apps/web/components/terminal/terminal-events.tsx @@ -0,0 +1,262 @@ +'use client' + +import {clsx} from 'clsx/lite' +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'} +] + +export function TerminalEvents() { + const {events} = 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 + + // 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 && ( +
+ + query + + + {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 index e7a2e4e..9df47c2 100644 --- a/apps/web/components/terminal/terminal-header.tsx +++ b/apps/web/components/terminal/terminal-header.tsx @@ -10,10 +10,10 @@ export function TerminalHeader() {
- UNRAG DEBUG + Unrag debug session {shortSessionId}... diff --git a/apps/web/components/terminal/terminal-mock-data.ts b/apps/web/components/terminal/terminal-mock-data.ts index 328a04a..7842def 100644 --- a/apps/web/components/terminal/terminal-mock-data.ts +++ b/apps/web/components/terminal/terminal-mock-data.ts @@ -137,34 +137,4 @@ export const mockChunkDetails: Record = { ] } -/** - * Logo pixel art data from /packages/unrag/registry/debug/tui/assets/unragLogo.ts - * 'g' = filled pixel (rendered as block), 'u' = empty pixel (whitespace) - */ -export const UNRAG_LOGO_LINES: string[] = [ - 'gggggggggguuuuuugggggggggguuuuugggggggguuuuuuuuggggggggguuuuuuggggggggggggggggguuuuuuuuuuuuuuuuuuuuuggggggggggguuuuuuuuuuuuuuuuuuuggggggggggggguuuuuuu', - 'gggggggggguuuuuugggggggggguuuuuggggggggguuuuuuuggggggggguuuuuugggggggggggggggggggguuuuuuuuuuuuuuuuuggggggggggggguuuuuuuuuuuuuuuuggggggggggggggggguuuuu', - 'gggggggggguuuuuugggggggggguuuuugggggggggguuuuuuggggggggguuuuuugggggggggggggggggggggguuuuuuuuuuuuuuuggggggggggggguuuuuuuuuuuuuugggggggggggggggggggguuuu', - 'gggggggggguuuuuugggggggggguuuuuggggggggggguuuuuggggggggguuuuuuggggggggggggggggggggggguuuuuuuuuuuuugggggggggggggguuuuuuuuuuuuuggggggggggggggggggggggguu', - 'gggggggggguuuuuuggggguuuuuuuuuuggggggggggguuuuuggggggggguuuuuugggggggggggggggggggggggguuuuuuuuuuuuggggggggggggggguuuuuuuuuuugggggggggggggggggggggggggu', - 'gggggggggguuuuuuuuuuuuuuuuuuuuugggggggggggguuuuggggggggguuuuuuggggggggggggggggggggggggguuuuuuuuuugggggggggggggggguuuuuuuuuuggggggggggggguugggggggggggu', - 'ggggggggguuuuuuuuuuuuuuggguuuuuggggggggggggguuuggggggggguuuuuugggggggggguuuuggggggggggguuuuuuuuuuggggggggggggggggguuuuuuuuuggggggggggguuuuuggggggggggg', - 'gggggguuuuuuuuuuuggggggggguuuuugggggggggggggguuggggggggguuuuuugggggggggguuuuugggggggggguuuuuuuuuuggggggggggggggggguuuuuuuuugggggggggguuuuuuugggggggggg', - 'uuuuuuuuuuuuuuuugggggggggguuuuugggggggggggggguuggggggggguuuuuugggggggggguuuuugggggggggguuuuuuuuuggggggggggggggggggguuuuuuugggggggggguuuuuuuugggggggggg', - 'uuuuuuuggguuuuuugggggggggguuuuuggggggggggggggguggggggggguuuuuugggggggggguuuugggggggggguuuuuuuuuuggggggggguggggggggguuuuuuugggggggggguuuuuuuuuuuuuuuuuu', - 'uugggggggguuuuuugggggggggguuuuuggggggggggggggggggggggggguuuuuugggggggggggggggggggggggguuuuuuuuugggggggggguggggggggguuuuuuugggggggggguuuugggggggggggggg', - 'gggggggggguuuuuugggggggggguuuuuggggggggggggggggggggggggguuuuuugggggggggggggggggggggguuuuuuuuuuuggggggggguugggggggggguuuuuugggggggggguuuugggggggggggggg', - 'gggggggggguuuuuugggggggggguuuuuggggggggggggggggggggggggguuuuuugggggggggggggggggggguuuuuuuuuuuugggggggggguuuggggggggguuuuuugggggggggguuuugggggggggggggg', - 'gggggggggguuuuuugggggggggguuuuuggggggggguggggggggggggggguuuuuuggggggggggggggggggggggguuuuuuuuuggggggggguuuugggggggggguuuuugggggggggguuuugggggggggggggg', - 'gggggggggguuuuuugggggggggguuuuuggggggggguugggggggggggggguuuuuugggggggggggggggggggggggguuuuuuuuggggggggggggggggggggggguuuuugggggggggguuuugggggggggggggg', - 'gggggggggguuuuuugggggggggguuuuuggggggggguuuggggggggggggguuuuuuggggggggggugggggggggggggguuuuuugggggggggggggggggggggggguuuuuggggggggggguuuuuuuuggggggggg', - 'gggggggggguuuuuugggggggggguuuuuggggggggguuuggggggggggggguuuuuugggggggggguuuuugggggggggguuuuuuggggggggggggggggggggggggguuuuuggggggggggguuuuuugggggggggg', - 'ggggggggggguuuugggggggggguuuuuuggggggggguuuugggggggggggguuuuuugggggggggguuuuugggggggggguuuuugggggggggggggggggggggggggguuuuuggggggggggggguuuggggggggggg', - 'ggggggggggggggggggggggggguuuuuuggggggggguuuuuggggggggggguuuuuugggggggggguuuuugggggggggguuuuuggggggggggggggggggggggggggguuuuugggggggggggggggggggggggggg', - 'uggggggggggggggggggggggguuuuuuuggggggggguuuuuugggggggggguuuuuugggggggggguuuuugggggggggguuuuuggggggggguuuuuuuugggggggggguuuuuuggggggggggggggggggggggggg', - 'uuggggggggggggggggggggguuuuuuuuggggggggguuuuuugggggggggguuuuuugggggggggguuuuuugggggggggguuugggggggggguuuuuuuuugggggggggguuuuuugggggggggggggggggggggggg', - 'uuuugggggggggggggggggguuuuuuuuuggggggggguuuuuuuggggggggguuuuuugggggggggguuuuuugggggggggguuuggggggggguuuuuuuuuugggggggggguuuuuuuugggggggggggggggugggggg', - 'uuuuuuugggggggggggguuuuuuuuuuuuggggggggguuuuuuuugggggggguuuuuuggggggggguuuuuuugggggggggguugggggggggguuuuuuuuuugggggggggguuuuuuuuuuuggggggggggguugggggg' -] - 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..a1e7529 --- /dev/null +++ b/apps/web/components/terminal/terminal-query.tsx @@ -0,0 +1,216 @@ +'use client' + +import {clsx} from 'clsx/lite' +import {useState, useEffect, useRef} 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 + +export function TerminalQuery() { + const {queryResults, lastQuery, runQuery, hasUserInteracted, stopAllAnimations} = 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) + + // 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) { + setQuery(lastQuery) + setHasInteracted(true) + setIsTyping(false) + } + }, [lastQuery, query]) + + // Typing animation effect + useEffect(() => { + if (!isTyping || hasInteracted || hasUserInteracted) 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) + // Auto-run query after typing animation + setTimeout(() => { + runQuery(EXAMPLE_QUERY) + }, 500) + } + }, TYPING_SPEED) + + return () => { + clearInterval(typeInterval) + } + }, [isTyping, hasInteracted, hasUserInteracted, runQuery]) + + 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() + if (query.trim()) { + runQuery(query) + } + } + + // Show results if we have any in context + const showResults = queryResults.length > 0 + const selectedResult = queryResults[selectedIndex] + + return ( +
+ {/* Query input */} +
+
+ {chars.arrow} + + k=8 + +
+
+ + {/* Results area */} +
+ {/* Results list */} +
+
+
+ + RESULTS + + + j/k navigate · enter inspect + +
+ {showResults && ( + + {queryResults.length} + + )} +
+
+ {showResults ? ( + queryResults.map((result, idx) => { + const isSelected = idx === selectedIndex + return ( + + ) + }) + ) : ( +
+ {isTyping + ? 'Typing query...' + : 'Enter a query and press SEARCH'} +
+ )} +
+
+ + {/* Details panel */} +
+
+ + PREVIEW + +
+ {showResults && selectedResult ? ( +
+
+ + {selectedResult.score.toFixed(2)} + + relevance +
+
+
+ source + + {selectedResult.sourceId} + +
+
+ chunk + + {selectedResult.content} + +
+
+
+ ) : ( +
+ Select a result to view details +
+ )} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-tab-bar.tsx b/apps/web/components/terminal/terminal-tab-bar.tsx index 16b5581..7cdf008 100644 --- a/apps/web/components/terminal/terminal-tab-bar.tsx +++ b/apps/web/components/terminal/terminal-tab-bar.tsx @@ -21,12 +21,10 @@ export function TerminalTabBar() { 'px-2 py-0.5 text-[10px] transition-colors duration-150 whitespace-nowrap', isActive ? 'text-black font-medium' - : 'text-white/60 hover:text-white/80' + : 'text-white/50 hover:text-white/70' )} style={ - isActive - ? {backgroundColor: theme.accent} - : undefined + isActive ? {backgroundColor: theme.accent} : undefined } > {tab.shortcut} {tab.label} diff --git a/apps/web/components/terminal/terminal-traces.tsx b/apps/web/components/terminal/terminal-traces.tsx new file mode 100644 index 0000000..b3e594a --- /dev/null +++ b/apps/web/components/terminal/terminal-traces.tsx @@ -0,0 +1,189 @@ +'use client' + +import {clsx} from 'clsx/lite' +import {useState} from 'react' +import {useTerminal} from './terminal-context' +import {chars, theme} from './terminal-theme' +import type {TerminalTrace} from './terminal-types' + +function WaterfallBar({ + stages, + totalMs +}: { + stages: TerminalTrace['stages'] + totalMs: number +}) { + const maxWidth = 200 + return ( +
+ {stages.map((stage, idx) => { + const width = Math.max(20, (stage.ms / totalMs) * maxWidth) + return ( +
+ + {stage.name} + + + {stage.ms}ms + +
+
+ ) + })} +
+ ) +} + +export function TerminalTraces() { + const {traces} = useTerminal() + const [selectedIndex, setSelectedIndex] = useState(0) + const selectedTrace = traces[selectedIndex] as TerminalTrace | undefined + + // 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, idx) => ( +
+ + {event.time} + + + {event.type} + +
+ ))} +
+
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/components/terminal/terminal-types.ts b/apps/web/components/terminal/terminal-types.ts index 22fac6e..8aeba5b 100644 --- a/apps/web/components/terminal/terminal-types.ts +++ b/apps/web/components/terminal/terminal-types.ts @@ -17,11 +17,52 @@ export interface Tab { 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 TerminalState { activeTab: TabId selectedDocIndex: number selectedChunkIndex: number isAnimating: boolean + events: TerminalEvent[] + traces: TerminalTrace[] + queryResults: QueryResult[] + lastQuery: string + hasUserInteracted: boolean } export interface TerminalActions { @@ -29,6 +70,8 @@ export interface TerminalActions { setSelectedDocIndex: (index: number) => void setSelectedChunkIndex: (index: number) => void setIsAnimating: (animating: boolean) => void + runQuery: (query: string) => void + stopAllAnimations: () => void } export interface TerminalContextValue extends TerminalState, TerminalActions {} diff --git a/apps/web/components/terminal/terminal.tsx b/apps/web/components/terminal/terminal.tsx index b75d345..3e8deb9 100644 --- a/apps/web/components/terminal/terminal.tsx +++ b/apps/web/components/terminal/terminal.tsx @@ -59,7 +59,7 @@ function TerminalPrompt({ } function TerminalTUI({onInteraction}: {onInteraction: () => void}) { - const {setSelectedDocIndex, setActiveTab} = useTerminal() + const {setSelectedDocIndex, setActiveTab, runQuery, stopAllAnimations, hasUserInteracted} = useTerminal() const autoPlayRef = useRef | null>(null) const [isAutoPlaying, setIsAutoPlaying] = useState(true) @@ -69,8 +69,20 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { autoPlayRef.current = null } setIsAutoPlaying(false) + stopAllAnimations() onInteraction() - }, [onInteraction]) + }, [onInteraction, stopAllAnimations]) + + // 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) { @@ -78,11 +90,21 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { } const sequence: Array<{action: () => void; delay: number}> = [ - {action: () => setSelectedDocIndex(1), delay: 2500}, - {action: () => setSelectedDocIndex(2), delay: 1800}, - {action: () => setSelectedDocIndex(0), delay: 1800}, - {action: () => setActiveTab('dashboard' as TabId), delay: 3000}, - {action: () => setActiveTab('docs' as TabId), delay: 2500} + // Run initial query to populate events/traces + {action: () => runQuery('how does unrag ingest work?'), delay: 1500}, + // Show events + {action: () => setActiveTab('events' as TabId), delay: 2000}, + // Show traces with waterfall + {action: () => setActiveTab('traces' as TabId), delay: 2500}, + // Go to query and run another query + {action: () => setActiveTab('query' as TabId), delay: 2500}, + // Go to docs + {action: () => setActiveTab('docs' as TabId), delay: 3500}, + {action: () => setSelectedDocIndex(1), delay: 2000}, + {action: () => setSelectedDocIndex(2), delay: 1500}, + {action: () => setSelectedDocIndex(0), delay: 1500}, + // Back to dashboard + {action: () => setActiveTab('dashboard' as TabId), delay: 2500} ] let currentIndex = 0 @@ -111,7 +133,7 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { clearTimeout(autoPlayRef.current) } } - }, [isAutoPlaying, setSelectedDocIndex, setActiveTab]) + }, [isAutoPlaying, setSelectedDocIndex, setActiveTab, runQuery]) return ( From ff39bda154d6dee50469a4e1cb8f6b6d58890377 Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 15:59:15 +0530 Subject: [PATCH 05/11] feat: Add document ingest functionality, hotkey navigation, and responsive styling improvements to the terminal. --- apps/web/app/global.css | 9 + apps/web/components/elements.tsx | 51 ++- apps/web/components/home/hero.tsx | 2 +- .../components/terminal/terminal-content.tsx | 9 +- .../components/terminal/terminal-context.tsx | 327 ++++++++++++---- .../terminal/terminal-dashboard.tsx | 20 +- .../web/components/terminal/terminal-docs.tsx | 36 +- .../components/terminal/terminal-events.tsx | 144 ++++--- .../components/terminal/terminal-header.tsx | 13 +- .../components/terminal/terminal-ingest.tsx | 190 ++++++++++ .../web/components/terminal/terminal-logo.tsx | 10 +- .../components/terminal/terminal-query.tsx | 356 ++++++++++++++---- .../terminal/terminal-status-bar.tsx | 4 +- .../components/terminal/terminal-tab-bar.tsx | 8 +- .../web/components/terminal/terminal-theme.ts | 5 +- .../components/terminal/terminal-traces.tsx | 137 +++++-- .../web/components/terminal/terminal-types.ts | 31 ++ apps/web/components/terminal/terminal.tsx | 134 +++++-- apps/web/package.json | 5 +- bun.lock | 3 + 20 files changed, 1150 insertions(+), 344 deletions(-) create mode 100644 apps/web/components/terminal/terminal-ingest.tsx 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 ( + +
+
+ {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 index 8be1ef6..127b47f 100644 --- a/apps/web/components/terminal/terminal-logo.tsx +++ b/apps/web/components/terminal/terminal-logo.tsx @@ -4,8 +4,14 @@ import Image from 'next/image' export function TerminalLogo() { return ( -
- Unrag +
+ Unrag
) } diff --git a/apps/web/components/terminal/terminal-query.tsx b/apps/web/components/terminal/terminal-query.tsx index a1e7529..5dc5110 100644 --- a/apps/web/components/terminal/terminal-query.tsx +++ b/apps/web/components/terminal/terminal-query.tsx @@ -1,20 +1,52 @@ 'use client' -import {clsx} from 'clsx/lite' -import {useState, useEffect, useRef} from 'react' +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 {queryResults, lastQuery, runQuery, hasUserInteracted, stopAllAnimations} = useTerminal() + 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 [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(() => { @@ -26,16 +58,36 @@ export function TerminalQuery() { // Sync query with lastQuery from context when component mounts useEffect(() => { - if (lastQuery && !query) { + if (lastQuery && !query && !hasInteracted && !hasUserInteracted) { setQuery(lastQuery) setHasInteracted(true) setIsTyping(false) } - }, [lastQuery, query]) + }, [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 (!isTyping || hasInteracted || hasUserInteracted) { + return + } + + if (shouldReduceMotion) { + setQuery(EXAMPLE_QUERY) + setIsTyping(false) + runQuery(EXAMPLE_QUERY) + return + } let charIndex = 0 const typeInterval = setInterval(() => { @@ -45,17 +97,20 @@ export function TerminalQuery() { } else { clearInterval(typeInterval) setIsTyping(false) - // Auto-run query after typing animation - setTimeout(() => { - runQuery(EXAMPLE_QUERY) - }, 500) + runQuery(EXAMPLE_QUERY) } }, TYPING_SPEED) return () => { clearInterval(typeInterval) } - }, [isTyping, hasInteracted, hasUserInteracted, runQuery]) + }, [ + isTyping, + hasInteracted, + hasUserInteracted, + runQuery, + shouldReduceMotion + ]) const handleFocus = () => { setHasInteracted(true) @@ -72,34 +127,158 @@ export function TerminalQuery() { 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 = queryResults.length > 0 + 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} - - k=8 +
+ + + {chars.arrow} + +
+ + {isTyping && !hasInteracted && !hasUserInteracted && ( + + )} +
+ + k=8 +
{/* Results area */} -
+
{/* Results list */} -
-
+
+
- + RESULTS - + j/k navigate · enter inspect
- {showResults && ( - + {isQuerying ? ( + + QUERYING... + + ) : showResults ? ( + {queryResults.length} - )} + ) : null}
-
+
{showResults ? ( - queryResults.map((result, idx) => { - const isSelected = idx === selectedIndex - return ( - - ) - }) + + {chars.check} + + + {result.score.toFixed(2)} + + + {result.sourceId} + + + ) + })} + ) : ( -
- {isTyping - ? 'Typing query...' - : 'Enter a query and press SEARCH'} +
+ {emptyMessage}
)}
{/* Details panel */} -
-
+
+
PREVIEW
{showResults && selectedResult ? ( -
+
- + {selectedResult.score.toFixed(2)} relevance
- source + + source + {selectedResult.sourceId}
- chunk + + chunk + {selectedResult.content} @@ -205,8 +399,10 @@ export function TerminalQuery() {
) : ( -
- Select a result to view details +
+ {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 index 237f50a..21d837d 100644 --- a/apps/web/components/terminal/terminal-status-bar.tsx +++ b/apps/web/components/terminal/terminal-status-bar.tsx @@ -3,7 +3,7 @@ import {theme} from './terminal-theme' const shortcuts = [ - {key: '1-7', action: 'tabs'}, + {key: '1-8', action: 'tabs'}, {key: 'j/k', action: 'navigate'}, {key: 'enter', action: 'select'}, {key: '?', action: 'help'}, @@ -12,7 +12,7 @@ const shortcuts = [ export function TerminalStatusBar() { return ( -
+
{shortcuts.map((shortcut) => (
+
{TABS.map((tab) => { const isActive = activeTab === tab.id return ( @@ -18,13 +18,15 @@ export function TerminalTabBar() { key={tab.id} onClick={() => setActiveTab(tab.id as TabId)} className={clsx( - 'px-2 py-0.5 text-[10px] transition-colors duration-150 whitespace-nowrap', + 'whitespace-nowrap px-1.5 py-0.5 text-[9px] transition-colors duration-150 sm:px-2 sm:text-[10px]', isActive ? 'text-black font-medium' : 'text-white/50 hover:text-white/70' )} style={ - isActive ? {backgroundColor: theme.accent} : undefined + isActive + ? {backgroundColor: theme.accent} + : undefined } > {tab.shortcut} {tab.label} diff --git a/apps/web/components/terminal/terminal-theme.ts b/apps/web/components/terminal/terminal-theme.ts index 0ab8956..19d5d3c 100644 --- a/apps/web/components/terminal/terminal-theme.ts +++ b/apps/web/components/terminal/terminal-theme.ts @@ -62,6 +62,7 @@ export const TABS = [ {id: 'traces', label: 'Traces', shortcut: 3}, {id: 'query', label: 'Query', shortcut: 4}, {id: 'docs', label: 'DOCS', shortcut: 5}, - {id: 'doctor', label: 'Doctor', shortcut: 6}, - {id: 'eval', label: 'Eval', shortcut: 7} + {id: 'ingest', label: 'Ingest', shortcut: 6}, + {id: 'doctor', label: 'Doctor', shortcut: 7}, + {id: 'eval', label: 'Eval', shortcut: 8} ] as const diff --git a/apps/web/components/terminal/terminal-traces.tsx b/apps/web/components/terminal/terminal-traces.tsx index b3e594a..5c204bb 100644 --- a/apps/web/components/terminal/terminal-traces.tsx +++ b/apps/web/components/terminal/terminal-traces.tsx @@ -1,11 +1,29 @@ 'use client' -import {clsx} from 'clsx/lite' +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 {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 @@ -16,14 +34,17 @@ function WaterfallBar({ const maxWidth = 200 return (
- {stages.map((stage, idx) => { + {stages.map((stage) => { const width = Math.max(20, (stage.ms / totalMs) * maxWidth) return ( -
- +
+ {stage.name} - + {stage.ms}ms
{ + 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 + + 0 +
{/* Empty state */} -
+
No traces yet. Run a query to see traces.
@@ -73,23 +130,25 @@ export function TerminalTraces() { return (
{/* Header */} -
+
- + TRACES - j/k navigate + + j/k navigate +
- + {traces.length}
{/* Main content */} -
+
{/* Traces list */} -
-
+
+
{traces.map((trace, idx) => { const isSelected = idx === selectedIndex return ( @@ -97,19 +156,22 @@ export function TerminalTraces() { key={trace.id} type="button" onClick={() => setSelectedIndex(idx)} - className={clsx( - 'w-full text-left px-4 py-1.5 text-[10px] flex items-center gap-3 transition-colors', + 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' )} > - {isSelected ? chars.arrow : ' '} + {chars.arrow} {trace.time} @@ -134,18 +196,17 @@ export function TerminalTraces() {
{/* Details panel */} -
+
{selectedTrace && ( -
+
{/* Waterfall header */}
- + WATERFALL - {selectedTrace.id} + + {selectedTrace.id} +
{/* Stage bars */} @@ -157,23 +218,23 @@ export function TerminalTraces() { {/* Events section */}
- + EVENTS - + {selectedTrace.events.length} shown
- {selectedTrace.events.map((event, idx) => ( + {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 index 8aeba5b..16ceea0 100644 --- a/apps/web/components/terminal/terminal-types.ts +++ b/apps/web/components/terminal/terminal-types.ts @@ -8,6 +8,7 @@ export type TabId = | 'traces' | 'query' | 'docs' + | 'ingest' | 'doctor' | 'eval' @@ -53,16 +54,44 @@ export interface QueryResult { 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 { @@ -72,6 +101,8 @@ export interface TerminalActions { setIsAnimating: (animating: boolean) => void runQuery: (query: string) => void stopAllAnimations: () => void + resetTerminal: () => void + ingestDocument: (input: IngestInput) => void } export interface TerminalContextValue extends TerminalState, TerminalActions {} diff --git a/apps/web/components/terminal/terminal.tsx b/apps/web/components/terminal/terminal.tsx index 3e8deb9..224a1d9 100644 --- a/apps/web/components/terminal/terminal.tsx +++ b/apps/web/components/terminal/terminal.tsx @@ -1,6 +1,7 @@ 'use client' -import {clsx} from 'clsx/lite' +import {cn} from '@/lib/utils' +import {useHotkeys} from '@mantine/hooks' import {AnimatePresence, motion} from 'motion/react' import {useCallback, useEffect, useRef, useState} from 'react' import {TerminalContent} from './terminal-content' @@ -9,21 +10,47 @@ 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() { return ( -
+
- - - + + +
- + Terminal
@@ -40,7 +67,7 @@ function TerminalPrompt({ showCursor?: boolean }) { return ( -
+
vercel $ {command} @@ -59,7 +86,14 @@ function TerminalPrompt({ } function TerminalTUI({onInteraction}: {onInteraction: () => void}) { - const {setSelectedDocIndex, setActiveTab, runQuery, stopAllAnimations, hasUserInteracted} = useTerminal() + const { + setSelectedDocIndex, + setActiveTab, + stopAllAnimations, + hasUserInteracted, + resetTerminal, + ingestDocument + } = useTerminal() const autoPlayRef = useRef | null>(null) const [isAutoPlaying, setIsAutoPlaying] = useState(true) @@ -73,6 +107,20 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { 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) { @@ -89,22 +137,36 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { return } + resetTerminal() + setActiveTab('dashboard' as TabId) + const sequence: Array<{action: () => void; delay: number}> = [ - // Run initial query to populate events/traces - {action: () => runQuery('how does unrag ingest work?'), delay: 1500}, - // Show events - {action: () => setActiveTab('events' as TabId), delay: 2000}, + // 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: 2500}, - // Go to query and run another query - {action: () => setActiveTab('query' as TabId), delay: 2500}, + {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: 3500}, - {action: () => setSelectedDocIndex(1), delay: 2000}, - {action: () => setSelectedDocIndex(2), delay: 1500}, - {action: () => setSelectedDocIndex(0), delay: 1500}, - // Back to dashboard - {action: () => setActiveTab('dashboard' as TabId), delay: 2500} + {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 @@ -133,14 +195,19 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { clearTimeout(autoPlayRef.current) } } - }, [isAutoPlaying, setSelectedDocIndex, setActiveTab, runQuery]) + }, [ + ingestDocument, + isAutoPlaying, + resetTerminal, + setActiveTab, + setSelectedDocIndex + ]) return ( void}) { ) } -function TerminalInner({autoPlay, className}: TerminalProps) { +function TerminalInner({autoPlay = false, className}: TerminalProps) { const [isTyping, setIsTyping] = useState(autoPlay) const [typedCommand, setTypedCommand] = useState(autoPlay ? '' : COMMAND) const [showTUI, setShowTUI] = useState(!autoPlay) - const [hasInteracted, setHasInteracted] = useState(false) useEffect(() => { - if (!autoPlay || !isTyping) return + if (!autoPlay || !isTyping) { + return + } let charIndex = 0 const typeInterval = setInterval(() => { @@ -182,14 +250,12 @@ function TerminalInner({autoPlay, className}: TerminalProps) { } }, [autoPlay, isTyping]) - const handleInteraction = useCallback(() => { - setHasInteracted(true) - }, []) + const handleInteraction = useCallback(() => {}, []) return (
@@ -205,11 +271,7 @@ function TerminalInner({autoPlay, className}: TerminalProps) { {/* TUI content - appears after typing completes */} - {showTUI && ( - - )} + {showTUI && }
) 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/bun.lock b/bun.lock index 458efeb..b13e7d4 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,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", @@ -458,6 +459,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=="], From 37544f210235ac3924cd8cfe72f79fd82c83bfd8 Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 17:26:47 +0530 Subject: [PATCH 06/11] feat: Implement ingest operation tracing, remove 'doctor' and 'eval' terminal tabs, and adjust hero section spacing. --- apps/web/components/home/hero.tsx | 2 +- .../components/terminal/terminal-context.tsx | 20 +++++++++++++++++++ .../terminal/terminal-status-bar.tsx | 2 +- .../web/components/terminal/terminal-theme.ts | 4 +--- .../web/components/terminal/terminal-types.ts | 2 -- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/web/components/home/hero.tsx b/apps/web/components/home/hero.tsx index e159d72..f6b549d 100644 --- a/apps/web/components/home/hero.tsx +++ b/apps/web/components/home/hero.tsx @@ -33,7 +33,7 @@ function HeroLeftAlignedWithDemo({ return (
-
+
{eyebrow} {headline} diff --git a/apps/web/components/terminal/terminal-context.tsx b/apps/web/components/terminal/terminal-context.tsx index e06de30..3626da2 100644 --- a/apps/web/components/terminal/terminal-context.tsx +++ b/apps/web/components/terminal/terminal-context.tsx @@ -183,6 +183,7 @@ export function TerminalProvider({ return } + const traceId = generateId() const sourceId = input.sourceId.trim() || `debug:${generateId()}` const chunkSize = Math.max(40, input.chunkSize ?? 120) const overlap = Math.min( @@ -228,9 +229,28 @@ export function TerminalProvider({ } ] + const newTrace: TerminalTrace = { + id: traceId, + opName: 'INGEST', + time, + label: `source ${sourceId.slice(0, 28)}${sourceId.length > 28 ? '...' : ''} [${(totalMs / 1000).toFixed(1)}s]`, + totalMs: totalMs.toString(), + stages: [ + {name: 'CHUNK', ms: chunkMs, color: '#ffffff'}, + {name: 'EMBED', ms: embedMs, color: theme.accent} + ], + events: [ + {time, type: 'ingest:start'}, + {time, type: 'ingest:chunk-complete'}, + {time, type: 'ingest:embedding-complete'}, + {time, type: 'ingest:complete'} + ] + } + setIngestedDocuments((prev) => [document, ...prev]) setIngestedChunks((prev) => [...chunks, ...prev]) setEvents((prev) => [...newEvents, ...prev]) + setTraces((prev) => [newTrace, ...prev]) }, []) const runQuery = useCallback( diff --git a/apps/web/components/terminal/terminal-status-bar.tsx b/apps/web/components/terminal/terminal-status-bar.tsx index 21d837d..27bce72 100644 --- a/apps/web/components/terminal/terminal-status-bar.tsx +++ b/apps/web/components/terminal/terminal-status-bar.tsx @@ -3,7 +3,7 @@ import {theme} from './terminal-theme' const shortcuts = [ - {key: '1-8', action: 'tabs'}, + {key: '1-6', action: 'tabs'}, {key: 'j/k', action: 'navigate'}, {key: 'enter', action: 'select'}, {key: '?', action: 'help'}, diff --git a/apps/web/components/terminal/terminal-theme.ts b/apps/web/components/terminal/terminal-theme.ts index 19d5d3c..84ae4d8 100644 --- a/apps/web/components/terminal/terminal-theme.ts +++ b/apps/web/components/terminal/terminal-theme.ts @@ -62,7 +62,5 @@ export const TABS = [ {id: 'traces', label: 'Traces', shortcut: 3}, {id: 'query', label: 'Query', shortcut: 4}, {id: 'docs', label: 'DOCS', shortcut: 5}, - {id: 'ingest', label: 'Ingest', shortcut: 6}, - {id: 'doctor', label: 'Doctor', shortcut: 7}, - {id: 'eval', label: 'Eval', shortcut: 8} + {id: 'ingest', label: 'Ingest', shortcut: 6} ] as const diff --git a/apps/web/components/terminal/terminal-types.ts b/apps/web/components/terminal/terminal-types.ts index 16ceea0..da23796 100644 --- a/apps/web/components/terminal/terminal-types.ts +++ b/apps/web/components/terminal/terminal-types.ts @@ -9,8 +9,6 @@ export type TabId = | 'query' | 'docs' | 'ingest' - | 'doctor' - | 'eval' export interface Tab { id: TabId From cf8875f6e32bab2ade4497a6599aee7f8ec92f6d Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 17:52:21 +0530 Subject: [PATCH 07/11] feat: implement a draggable terminal component by adding a title bar pointer down prop to the terminal and integrating it into the hero section. --- .../components/home/draggable-terminal.tsx | 36 +++++++++++++++++++ apps/web/components/home/hero.tsx | 6 ++-- .../web/components/terminal/terminal-types.ts | 3 ++ apps/web/components/terminal/terminal.tsx | 29 +++++++++++---- 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 apps/web/components/home/draggable-terminal.tsx diff --git a/apps/web/components/home/draggable-terminal.tsx b/apps/web/components/home/draggable-terminal.tsx new file mode 100644 index 0000000..65af01c --- /dev/null +++ b/apps/web/components/home/draggable-terminal.tsx @@ -0,0 +1,36 @@ +'use client' + +import type {ComponentProps, PointerEventHandler} from 'react' +import {motion, useDragControls} from 'motion/react' +import {Terminal} from '../terminal' + +type DraggableTerminalProps = Omit< + ComponentProps, + 'onTitleBarPointerDown' +> + +export function DraggableTerminal({className, ...props}: DraggableTerminalProps) { + const dragControls = useDragControls() + + const handleTitleBarPointerDown: PointerEventHandler = ( + event + ) => { + event.preventDefault() + dragControls.start(event) + } + + return ( + + + + ) +} diff --git a/apps/web/components/home/hero.tsx b/apps/web/components/home/hero.tsx index f6b549d..59d607b 100644 --- a/apps/web/components/home/hero.tsx +++ b/apps/web/components/home/hero.tsx @@ -11,7 +11,7 @@ import { Text } from '../elements' import {ArrowNarrowRightIcon} from '../icons' -import {Terminal} from '../terminal' +import {DraggableTerminal} from './draggable-terminal' function HeroLeftAlignedWithDemo({ eyebrow, @@ -126,14 +126,14 @@ export function HeroSection() { demo={ <> - - } diff --git a/apps/web/components/terminal/terminal.tsx b/apps/web/components/terminal/terminal.tsx index 224a1d9..3909e34 100644 --- a/apps/web/components/terminal/terminal.tsx +++ b/apps/web/components/terminal/terminal.tsx @@ -3,6 +3,7 @@ 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' @@ -42,9 +43,16 @@ function isEditableTarget() { return false } -function TerminalTitleBar() { +function TerminalTitleBar({ + onPointerDown +}: { + onPointerDown?: PointerEventHandler +}) { return ( -
+
@@ -221,7 +229,11 @@ function TerminalTUI({onInteraction}: {onInteraction: () => void}) { ) } -function TerminalInner({autoPlay = false, className}: TerminalProps) { +function TerminalInner({ + autoPlay = false, + className, + onTitleBarPointerDown +}: TerminalProps) { const [isTyping, setIsTyping] = useState(autoPlay) const [typedCommand, setTypedCommand] = useState(autoPlay ? '' : COMMAND) const [showTUI, setShowTUI] = useState(!autoPlay) @@ -260,7 +272,7 @@ function TerminalInner({autoPlay = false, className}: TerminalProps) { )} > {/* macOS-style title bar - always visible */} - + {/* Command line - always visible once typing starts */} - + ) } From 29cfa5f78701e0f196a906685cf78068a576c53e Mon Sep 17 00:00:00 2001 From: Sourabh Rathour Date: Tue, 20 Jan 2026 20:17:27 +0530 Subject: [PATCH 08/11] feat: Implement resizing for the draggable terminal and update related styling. --- .../components/home/draggable-terminal.tsx | 274 +++++++++++++++++- apps/web/components/home/hero.tsx | 26 +- .../web/components/terminal/terminal-theme.ts | 2 +- .../web/components/terminal/terminal-types.ts | 4 +- apps/web/components/terminal/terminal.tsx | 18 +- 5 files changed, 299 insertions(+), 25 deletions(-) diff --git a/apps/web/components/home/draggable-terminal.tsx b/apps/web/components/home/draggable-terminal.tsx index 65af01c..f9298ab 100644 --- a/apps/web/components/home/draggable-terminal.tsx +++ b/apps/web/components/home/draggable-terminal.tsx @@ -1,7 +1,9 @@ 'use client' +import {cn} from '@/lib/utils' import type {ComponentProps, PointerEventHandler} from 'react' -import {motion, useDragControls} from 'motion/react' +import {motion, useMotionValue} from 'motion/react' +import {useCallback, useRef, useState} from 'react' import {Terminal} from '../terminal' type DraggableTerminalProps = Omit< @@ -10,27 +12,283 @@ type DraggableTerminalProps = Omit< > export function DraggableTerminal({className, ...props}: DraggableTerminalProps) { - const dragControls = useDragControls() + const dragX = useMotionValue(0) + const dragY = useMotionValue(0) + const dragStateRef = useRef<{ + startX: number + startY: number + startDragX: number + startDragY: number + } | null>(null) + const resizeStateRef = useRef<{ + startX: number + startY: number + startWidth: number + startHeight: number + startDragX: number + startDragY: number + edges: { + left: boolean + right: boolean + top: boolean + bottom: boolean + } + } | null>(null) + const [size, setSize] = useState<{width: number; height: number} | null>( + null + ) + + const minWidth = 320 + const minHeight = 360 + const isResized = size !== null + + const handleDragPointerMove = useCallback( + (event: PointerEvent) => { + const state = dragStateRef.current + if (!state) { + return + } + dragX.set(state.startDragX + (event.clientX - state.startX)) + dragY.set(state.startDragY + (event.clientY - state.startY)) + }, + [dragX, dragY] + ) + + const handleDragPointerUp = useCallback(() => { + dragStateRef.current = null + window.removeEventListener('pointermove', handleDragPointerMove) + window.removeEventListener('pointerup', handleDragPointerUp) + window.removeEventListener('pointercancel', handleDragPointerUp) + }, [handleDragPointerMove]) const handleTitleBarPointerDown: PointerEventHandler = ( event ) => { event.preventDefault() - dragControls.start(event) + dragStateRef.current = { + startX: event.clientX, + startY: event.clientY, + startDragX: dragX.get(), + startDragY: dragY.get() + } + window.addEventListener('pointermove', handleDragPointerMove) + window.addEventListener('pointerup', handleDragPointerUp) + window.addEventListener('pointercancel', handleDragPointerUp) + } + + const handleResizePointerDown = + (edges: { + left: boolean + right: boolean + top: boolean + bottom: boolean + }): PointerEventHandler => + (event) => { + event.preventDefault() + event.stopPropagation() + const node = event.currentTarget + const container = node.closest('[data-draggable-terminal="true"]') + if (!(container instanceof HTMLElement)) { + return + } + const rect = container.getBoundingClientRect() + resizeStateRef.current = { + startX: event.clientX, + startY: event.clientY, + startWidth: rect.width, + startHeight: rect.height, + startDragX: dragX.get(), + startDragY: dragY.get(), + edges + } + setSize({width: rect.width, height: rect.height}) + node.setPointerCapture(event.pointerId) + } + + const handleResizePointerMove: PointerEventHandler = ( + event + ) => { + const state = resizeStateRef.current + if (!state) { + return + } + const deltaX = event.clientX - state.startX + const deltaY = event.clientY - state.startY + + const widthFromRight = state.edges.right + ? state.startWidth + deltaX + : state.startWidth + const widthFromLeft = state.edges.left + ? state.startWidth - deltaX + : widthFromRight + const nextWidth = Math.max(minWidth, widthFromLeft) + + const heightFromBottom = state.edges.bottom + ? state.startHeight + deltaY + : state.startHeight + const heightFromTop = state.edges.top + ? state.startHeight - deltaY + : heightFromBottom + const nextHeight = Math.max(minHeight, heightFromTop) + + if (state.edges.left) { + const appliedDelta = state.startWidth - nextWidth + dragX.set(state.startDragX + appliedDelta) + } + if (state.edges.top) { + const appliedDelta = state.startHeight - nextHeight + dragY.set(state.startDragY + appliedDelta) + } + + setSize({width: nextWidth, height: nextHeight}) + } + + const handleResizePointerUp: PointerEventHandler = ( + event + ) => { + resizeStateRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) } return ( +