Files
riotpiao.com/components/InteractiveTerminal.tsx
T
Story Crater Bot da64fe930b
Build & Push Portfolio Image / build-push (push) Successful in 3m41s
feat: smooth collapse/expand animation for terminal toggle
2026-09-02 10:05:00 -07:00

369 lines
12 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
INTERVIEW-STYLE QUESTIONS:
• Cert rotation in production—downtime?
• Why Talos over standard K8s?
• Multi-region SQS failover—atomic safety?
• vLLM 60% latency: bottleneck & fix?
• Exactly-once delivery design?
• 2hr→20min deploy: root cause?
• K8s CRD reconciliation at scale?
homelab | RBC | AWS | ask anything`,
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
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)
}
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove)
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
}
// 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 ${
isDark
? 'bg-gray-950 border-gray-700'
: 'bg-white border-gray-300'
}`}
>
<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={() => 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`}
style={{
borderTopLeftRadius: 'inherit',
opacity: isResizing ? 1 : 0.5,
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" className="m-0.5 opacity-40">
<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>
)
}