Files
riotpiao.com/components/InteractiveTerminal.tsx
T

439 lines
14 KiB
TypeScript
Raw Normal View History

2026-08-18 18:33:49 -07:00
'use client'
import { motion, AnimatePresence } from 'framer-motion'
2026-08-18 18:33:49 -07:00
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
}
2026-08-18 18:33:49 -07:00
const commands: Record<string, string> = {
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
2026-08-18 18:33:49 -07:00
}
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)
2026-08-18 18:33:49 -07:00
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])
2026-08-18 18:33:49 -07:00
useEffect(() => {
const isMacOS = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
setIsMac(isMacOS)
2026-08-18 18:33:49 -07:00
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])
2026-08-18 18:33:49 -07:00
// 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') {
2026-08-18 18:33:49 -07:00
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
2026-08-18 18:33:49 -07:00
}
// Check for built-in commands
if (commands[lowerCmd]) {
setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])
setInput('')
return
}
2026-08-18 18:33:49 -07:00
// 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)
}
2026-08-18 18:33:49 -07:00
}
return (
<div className="fixed bottom-6 right-6 z-40">
<AnimatePresence mode="wait">
2026-08-18 18:33:49 -07:00
{!isOpen ? (
<motion.button
key="trigger"
2026-08-18 18:33:49 -07:00
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 }}
2026-08-18 18:33:49 -07:00
>
Ask Poimen ({isMac ? 'Cmd' : 'Ctrl'}+K)
</motion.button>
2026-08-18 18:33:49 -07:00
) : (
<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()
}
}}
2026-08-18 18:33:49 -07:00
>
<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>
2026-08-18 18:33:49 -07:00
<button
onClick={() => setIsOpen(false)}
className={isDark ? 'text-gray-400 hover:text-white' : 'text-gray-500 hover:text-gray-900'}
2026-08-18 18:33:49 -07:00
>
</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'
}`}
2026-08-18 18:33:49 -07:00
>
{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>
2026-08-18 18:33:49 -07:00
)}
{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}
2026-08-18 18:33:49 -07:00
</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>
)}
2026-08-18 18:33:49 -07:00
</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>
2026-08-18 18:33:49 -07:00
<input
ref={inputRef}
2026-08-18 18:33:49 -07:00
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isLoading) handleCommand(input)
2026-08-18 18:33:49 -07:00
}}
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' : ''}`}
2026-08-18 18:33:49 -07:00
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>
2026-08-18 18:33:49 -07:00
</motion.div>
)}
</AnimatePresence>
</div>
2026-08-18 18:33:49 -07:00
)
}