Files
riotpiao.com/components/InteractiveTerminal.tsx
2026-09-03 23:40:10 -07:00

439 lines
14 KiB
TypeScript
Raw Permalink 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 | ! 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<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>
)
}