Files
riotpiao.com/components/InteractiveTerminal.tsx
T
2026-09-03 23:19:56 -07:00

449 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 <span className="inline-block w-3">{frames[frame]}</span>
}
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<string, string> = {
help: `COMMANDS: help, /clear, ! kubectl
QUESTIONS TO EXPLORE:
Homelab & Infrastructure
• Did you build Kubernetes from scratch? How hard?
• What's this etcd consensus issue you hit?
• How does Talos Linux compare to kubeadm?
• ArgoCD vs pure Terraform—when to use each?
Distributed Systems & State Machines
• Did you use Temporal before? What problems did it solve?
• Multi-region deployment at AWS—how did you handle consistency?
• How do you detect and fix Terraform drift at scale?
• GraphQL federation with microservices—experience?
LLM & AI Systems
• How does Graph-RAG work? Why not just semantic search?
• vLLM optimization—what bottleneck did you hit?
• Natural language → executable workflow—how?
• OIDC + hierarchical RBAC for multi-tenant knowledge graphs?
Operations & Career
• 2hr→20min deploy at RBC—what was the root cause?
• How did you reduce cloud costs 40% across teams?
• Lessons from on-call at AWS Step Functions?
• What's your take on IaC tooling—Terraform, Pulumi, CDK?
Or just ask: "Tell me about X" or "How did you build Y?"`,
about: `Rock Liang - Senior Software Engineer
6+ years | Infrastructure × Backend × LLM Systems
AWS → RBC → Building production homelab
Ask me anything specific!`,
}
interface HistoryEntry {
cmd: string
output: string
thinking?: string
isStreaming?: boolean
}
export function InteractiveTerminal() {
const [input, setInput] = useState('')
const [history, setHistory] = useState<HistoryEntry[]>([])
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<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const resizeHandleRef = useRef<HTMLDivElement>(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 (
<div className="fixed bottom-6 right-6 z-40">
<AnimatePresence mode="wait">
{!isOpen ? (
<motion.button
key="trigger"
onClick={() => 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)
</motion.button>
) : (
<motion.div
key="terminal"
ref={containerRef}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
style={{ width: `${width}px`, height: `${height}px` }}
className={`rounded-lg shadow-2xl flex flex-col border relative user-select-none ${
isDark
? 'bg-gray-950 border-gray-700'
: 'bg-white border-gray-300'
}`}
tabIndex={-1}
onMouseDown={(e) => {
// Ensure terminal stays focused during interaction
if (e.target === containerRef.current) {
inputRef.current?.focus()
}
}}
>
<div className={`border-b px-4 py-3 flex justify-between items-center ${
isDark
? 'bg-gray-900 border-gray-700'
: 'bg-gray-100 border-gray-300'
}`}>
<span className={`font-mono text-xs ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Poimen (AI Assistant)
</span>
<button
onClick={() => setIsOpen(false)}
className={isDark ? 'text-gray-400 hover:text-white' : 'text-gray-500 hover:text-gray-900'}
>
</button>
</div>
<div
ref={terminalRef}
className={`flex-1 overflow-y-auto px-4 py-3 font-mono text-sm space-y-4 ${
isDark ? 'text-gray-200' : 'text-gray-800'
}`}
>
{history.length === 0 && (
<div className={isDark ? 'text-gray-500' : 'text-gray-400'}>
Ask me about Rock&apos;s experience, projects, or skills!
<br />
<span className="text-xs">Type &apos;help&apos; for commands</span>
</div>
)}
{history.map((entry, i) => (
<div key={i} className="space-y-2">
<div className={isDark ? 'text-green-400' : 'text-green-600'}>
<span className={isDark ? 'text-blue-400' : 'text-blue-600'}></span> {entry.cmd}
</div>
{entry.isStreaming && !entry.output && (
<div className={`space-y-2 text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
<div className="flex items-start gap-2">
<Spinner />
<span className="italic truncate max-w-[300px]">
{entry.thinking ? entry.thinking.slice(-80) : 'Working...'}
</span>
</div>
<div className={`text-[11px] ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
(Reasoning on V100 32GB, slight delay expected)
</div>
</div>
)}
{entry.output && (
<div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}>
<ReactMarkdown>{entry.output}</ReactMarkdown>
{entry.isStreaming && (
<span className="animate-pulse"></span>
)}
</div>
)}
</div>
))}
</div>
<div className={`border-t px-4 py-3 ${isDark ? 'border-gray-700' : 'border-gray-300'}`}>
<div className="flex items-center gap-2">
<span className={isDark ? 'text-blue-400' : 'text-blue-600'}></span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => 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
/>
</div>
</div>
{/* Resize handle — top-left corner */}
<div
ref={resizeHandleRef}
onMouseDown={(e) => {
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,
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" className="m-0.5 opacity-40 pointer-events-none">
<line x1="0" y1="10" x2="10" y2="0" stroke="currentColor" strokeWidth="1.5" />
<line x1="0" y1="6" x2="6" y2="0" stroke="currentColor" strokeWidth="1.5" />
</svg>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}