feat(shell): real terminal with xterm.js + PTY over WebSocket Replace fake shell input with a full PTY-backed terminal using xterm.js. Apps like btop, vim, htop now work. AI chat panel is always visible. Backend: - Add WebSocket handler /api/ws/terminal with creack/pty - Allocate real pseudo-terminal with TERM=xterm-256color - Bidirectional I/O + dynamic resize via pty.Setsize - Skip JSON headers on /api/ws/* paths for WebSocket upgrade Frontend: - Integrate xterm.js with FitAddon and WebLinksAddon - Cyberpunk color theme matching app design - ResizeObserver for automatic terminal resizing - AI assistant panel always visible (340px, no toggle) - Connection status indicator (green/red dot) Dependencies: - Go: github.com/gorilla/websocket, github.com/creack/pty/v2 - npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -1,65 +1,142 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [history, setHistory] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [cwd, setCwd] = useState('~')
|
||||
const [showAi, setShowAi] = useState(false)
|
||||
const termRef = useRef(null)
|
||||
const fitAddonRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [cmdHistory, setCmdHistory] = useState([])
|
||||
const [histIdx, setHistIdx] = useState(-1)
|
||||
const outputRef = useRef(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
|
||||
}, [history])
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
const handleCommand = async (cmd) => {
|
||||
if (!cmd.trim()) return
|
||||
if (cmd === 'clear') { setHistory([]); return }
|
||||
const getWsUrl = useCallback(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}/api/ws/terminal`
|
||||
}, [])
|
||||
|
||||
setCmdHistory(prev => [...prev, cmd])
|
||||
setHistIdx(-1)
|
||||
setHistory(prev => [...prev, { type: 'cmd', text: `${cwd} $ ${cmd}` }])
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
try {
|
||||
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
|
||||
if (res.output) setHistory(prev => [...prev, { type: 'out', text: res.output }])
|
||||
if (res.error) setHistory(prev => [...prev, { type: 'err', text: res.error }])
|
||||
if (cmd.startsWith('cd ')) {
|
||||
const dir = cmd.slice(3).trim()
|
||||
setCwd(dir === '~' ? '~' : dir)
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: {
|
||||
background: '#0A0A0C',
|
||||
foreground: '#EAE0E2',
|
||||
cursor: '#FF0033',
|
||||
cursorAccent: '#0A0A0C',
|
||||
selectionBackground: '#FF003344',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#0A0A0C',
|
||||
red: '#FF0033',
|
||||
green: '#00E676',
|
||||
yellow: '#FFD740',
|
||||
blue: '#448AFF',
|
||||
magenta: '#FF1A5E',
|
||||
cyan: '#00BCD4',
|
||||
white: '#EAE0E2',
|
||||
brightBlack: '#5A4F52',
|
||||
brightRed: '#FF5252',
|
||||
brightGreen: '#69F0AE',
|
||||
brightYellow: '#FFFF00',
|
||||
brightBlue: '#82B1FF',
|
||||
brightMagenta: '#FF80AB',
|
||||
brightCyan: '#84FFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
},
|
||||
allowTransparency: false,
|
||||
scrollback: 5000,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(containerRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
const ws = new WebSocket(getWsUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true)
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
}
|
||||
} catch (err) {
|
||||
setHistory(prev => [...prev, { type: 'err', text: err.message }])
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleCommand(input)
|
||||
setInput('')
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (cmdHistory.length === 0) return
|
||||
const newIdx = histIdx === -1 ? cmdHistory.length - 1 : Math.max(0, histIdx - 1)
|
||||
setHistIdx(newIdx)
|
||||
setInput(cmdHistory[newIdx])
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (histIdx === -1) return
|
||||
const newIdx = histIdx + 1
|
||||
if (newIdx >= cmdHistory.length) { setHistIdx(-1); setInput('') }
|
||||
else { setHistIdx(newIdx); setInput(cmdHistory[newIdx]) }
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data)
|
||||
} else if (msg.type === 'error') {
|
||||
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false)
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false)
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
term.onResize(({ rows, cols }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
|
||||
}
|
||||
})
|
||||
|
||||
const onResize = () => {
|
||||
if (containerRef.current?.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(containerRef.current)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
}
|
||||
}, [getWsUrl])
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
@@ -78,60 +155,37 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal" style={{ height: '100%' }}>
|
||||
<div className="terminal" style={{ flex: 1 }}>
|
||||
<div className="shell-layout">
|
||||
<div className="shell-terminal-col">
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
{t('shell.terminal')}
|
||||
<span className="panel-subtitle">{cwd}</span>
|
||||
<span className={`connection-dot ${connected ? 'on' : 'off'}`} />
|
||||
</span>
|
||||
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
|
||||
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-output" ref={outputRef}>
|
||||
{history.map((line, i) => (
|
||||
<div key={i} className={`terminal-line ${line.type}`}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-input-bar">
|
||||
<span className="terminal-prompt">›</span>
|
||||
<input
|
||||
className="terminal-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-xterm-wrapper" ref={containerRef} />
|
||||
</div>
|
||||
|
||||
{showAi && (
|
||||
<div className="ai-panel">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages">
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
<div className="ai-panel-input">
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="ai-panel-input">
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user