'use client' import { motion, AnimatePresence } from 'framer-motion' import { useEffect, useRef, useState } from 'react' import { useTerminal } from '@/lib/TerminalContext' import ReactMarkdown from 'react-markdown' function Spinner() { const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const [frame, setFrame] = useState(0) useEffect(() => { const interval = setInterval(() => { setFrame(f => (f + 1) % frames.length) }, 80) return () => clearInterval(interval) }, [frames.length]) return {frames[frame]} } function useTheme() { const [isDark, setIsDark] = useState(true) useEffect(() => { const check = () => setIsDark(document.documentElement.classList.contains('dark')) check() const observer = new MutationObserver(check) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) return () => observer.disconnect() }, []) return isDark } const commands: Record = { help: `Commands: help | ! kubectl ... Quick questions: • etcd consensus issue at homelab? • Terraform drift detection? • Graph-RAG vs semantic search? • 2hr→20min deploy root cause? • How'd you reduce cloud costs 40%? • vLLM bottleneck & fix? • Temporal workflow orchestration? • ArgoCD vs Terraform split? Kubectl examples: ! kubectl get pods -A ! kubectl get nodes ! kubectl top pods ! kubectl logs -f deployment/poimen-mem -n portfolio Or just ask anything!`, about: `Rock Liang - Senior Software Engineer 6+ years | Infrastructure × Backend × LLM Systems AWS → RBC → Building production homelab Ask me anything!`, } interface HistoryEntry { cmd: string output: string thinking?: string isStreaming?: boolean } export function InteractiveTerminal() { const [input, setInput] = useState('') const [history, setHistory] = useState([]) const [isLoading, setIsLoading] = useState(false) const { isOpen, setIsOpen } = useTerminal() const [isMac, setIsMac] = useState(true) const [width, setWidth] = useState(448) // 28rem = 448px const [height, setHeight] = useState(512) // 32rem = 512px const [isResizing, setIsResizing] = useState(false) const terminalRef = useRef(null) const containerRef = useRef(null) const inputRef = useRef(null) const resizeHandleRef = useRef(null) const isDark = useTheme() // Close on click outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (isOpen && containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen, setIsOpen]) useEffect(() => { const isMacOS = /Mac|iPhone|iPad|iPod/.test(navigator.platform) setIsMac(isMacOS) const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault() setIsOpen(!isOpen) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isOpen, setIsOpen]) // Auto-scroll on history change useEffect(() => { terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight) }, [history]) // Focus input when opened useEffect(() => { if (isOpen) inputRef.current?.focus() }, [isOpen]) // Handle terminal resize (drag top-left, anchored bottom-right) useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return e.preventDefault() const container = containerRef.current if (!container) return const rect = container.getBoundingClientRect() const newWidth = Math.max(320, rect.right - e.clientX) const newHeight = Math.max(300, rect.bottom - e.clientY) setWidth(newWidth) setHeight(newHeight) } const handleMouseUp = () => { setIsResizing(false) document.body.style.cursor = 'auto' document.body.style.userSelect = 'auto' } if (isResizing) { document.body.style.userSelect = 'none' document.body.style.cursor = 'nw-resize' document.addEventListener('mousemove', handleMouseMove, { passive: false }) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } } }, [isResizing]) const handleCommand = async (cmd: string) => { const trimmed = cmd.trim() if (!trimmed) return const lowerCmd = trimmed.toLowerCase() if (lowerCmd === 'clear' || lowerCmd === '/clear') { setHistory([]) setInput('') return } // Handle ! kubectl commands if (trimmed.startsWith('!')) { const kubectlCmd = trimmed.slice(1).trim() if (!kubectlCmd.startsWith('kubectl ')) { setHistory(prev => [...prev, { cmd: trimmed, output: 'Error: only kubectl commands supported (e.g. ! kubectl get pods)' }]) setInput('') return } setIsLoading(true) setInput('') setHistory(prev => [...prev, { cmd: trimmed, output: '', isStreaming: true }]) const entryIndex = history.length try { const response = await fetch('/api/kubectl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: kubectlCmd }), }) const data = await response.json() setHistory(prev => { const updated = [...prev] const entry = updated[entryIndex] if (entry) { entry.output = data.error || data.output || 'No output' entry.isStreaming = false } return updated }) } catch (err) { setHistory(prev => { const updated = [...prev] const entry = updated[entryIndex] if (entry) { entry.output = `Error: ${err instanceof Error ? err.message : 'Request failed'}` entry.isStreaming = false } return updated }) } finally { setIsLoading(false) } return } // Check for built-in commands if (commands[lowerCmd]) { setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }]) setInput('') return } // Otherwise, call the LLM API setIsLoading(true) setInput('') const entryIndex = history.length setHistory(prev => [...prev, { cmd: trimmed, output: '', thinking: '', isStreaming: true }]) try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: trimmed }), }) if (!response.ok) throw new Error('API error') const reader = response.body?.getReader() if (!reader) throw new Error('No reader') const decoder = new TextDecoder() // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value, { stream: true }) const lines = chunk.split('\n') for (const line of lines) { if (!line.startsWith('data: ')) continue const data = line.slice(6) try { const json = JSON.parse(data) setHistory(prev => { const updated = [...prev] const entry = updated[entryIndex] if (!entry) return prev if (json.type === 'reasoning') { entry.thinking = json.text } else if (json.type === 'content') { entry.output = json.text } else if (json.type === 'done') { entry.isStreaming = false } return updated }) } catch { // Skip invalid JSON } } } } catch { setHistory(prev => { const updated = [...prev] const entry = updated[entryIndex] if (entry) { entry.output = 'Error: Failed to get response. Try again.' entry.isStreaming = false } return updated }) } finally { setIsLoading(false) } } return (
{!isOpen ? ( setIsOpen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-mono text-sm shadow-lg" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.2 }} > Ask Poimen ({isMac ? 'Cmd' : 'Ctrl'}+K) ) : ( { // Ensure terminal stays focused during interaction if (e.target === containerRef.current) { inputRef.current?.focus() } }} >
Poimen (AI Assistant)
{history.length === 0 && (
Ask me about Rock's experience, projects, or skills!
Type 'help' for commands
)} {history.map((entry, i) => (
{entry.cmd}
{entry.isStreaming && !entry.output && (
{entry.thinking ? entry.thinking.slice(-80) : 'Working...'}
(Reasoning on V100 32GB, slight delay expected)
)} {entry.output && (
{entry.output} {entry.isStreaming && ( )}
)}
))}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !isLoading) handleCommand(input) }} placeholder={isLoading ? 'Thinking...' : 'Ask anything...'} disabled={isLoading} className={`flex-1 bg-transparent text-sm outline-none ${ isDark ? 'text-gray-200 placeholder-gray-600' : 'text-gray-800 placeholder-gray-400' } ${isLoading ? 'opacity-50' : ''}`} autoFocus />
{/* Resize handle — top-left corner */}
{ e.preventDefault() e.stopPropagation() setIsResizing(true) }} className={`absolute top-0 left-0 w-4 h-4 cursor-nw-resize ${ isDark ? 'hover:bg-gray-600' : 'hover:bg-gray-400' } transition-colors z-10 select-none`} style={{ borderTopLeftRadius: 'inherit', opacity: isResizing ? 1 : 0.5, }} >
)}
) }