feat: implement web terminal feature with xterm.js and Socket.IO proxy

- Add CLI-side terminal management via Bun.Terminal with TerminalManager
- Implement server-side Socket.IO proxy for terminal I/O between web and CLI
- Create web terminal UI component with xterm.js and support for resize/reconnect
- Add terminal route and navigation button in session chat
- Include comprehensive terminal implementation plan and architecture docs
This commit is contained in:
weishu
2025-12-25 21:57:18 +08:00
parent 0762f0772f
commit 18e6310451
22 changed files with 1763 additions and 4 deletions
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from '@tanstack/react-router'
import type { Terminal } from '@xterm/xterm'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useSession } from '@/hooks/queries/useSession'
import { useTerminalSocket } from '@/hooks/useTerminalSocket'
import { TerminalView } from '@/components/Terminal/TerminalView'
import { LoadingState } from '@/components/LoadingState'
import { Badge } from '@/components/ui/badge'
function BackIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
)
}
function ConnectionBadge(props: { status: 'idle' | 'connecting' | 'connected' | 'error' }) {
switch (props.status) {
case 'connected':
return <Badge variant="success">Connected</Badge>
case 'connecting':
return <Badge variant="warning">Connecting</Badge>
case 'error':
return <Badge variant="destructive">Error</Badge>
default:
return <Badge variant="default">Idle</Badge>
}
}
export default function TerminalPage() {
const { sessionId } = useParams({ from: '/sessions/$sessionId/terminal' })
const { api, token } = useAppContext()
const goBack = useAppGoBack()
const { session } = useSession(api, sessionId)
const terminalId = useMemo(() => {
if (typeof crypto?.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}, [sessionId])
const terminalRef = useRef<Terminal | null>(null)
const inputDisposableRef = useRef<{ dispose: () => void } | null>(null)
const connectOnceRef = useRef(false)
const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null)
const [exitInfo, setExitInfo] = useState<{ code: number | null; signal: string | null } | null>(null)
const {
state: terminalState,
connect,
write,
resize,
disconnect,
onOutput,
onExit
} = useTerminalSocket({
token,
sessionId,
terminalId
})
useEffect(() => {
onOutput((data) => {
terminalRef.current?.write(data)
})
}, [onOutput])
useEffect(() => {
onExit((code, signal) => {
setExitInfo({ code, signal })
terminalRef.current?.write(`\r\n[process exited${code !== null ? ` with code ${code}` : ''}]`)
connectOnceRef.current = false
})
}, [onExit])
const handleTerminalMount = useCallback((terminal: Terminal) => {
terminalRef.current = terminal
inputDisposableRef.current?.dispose()
inputDisposableRef.current = terminal.onData((data) => {
write(data)
})
}, [write])
const handleResize = useCallback((cols: number, rows: number) => {
lastSizeRef.current = { cols, rows }
if (!session?.active) {
return
}
if (!connectOnceRef.current) {
connectOnceRef.current = true
connect(cols, rows)
} else {
resize(cols, rows)
}
}, [session?.active, connect, resize])
useEffect(() => {
if (!session?.active) {
return
}
if (connectOnceRef.current) {
return
}
const size = lastSizeRef.current
if (!size) {
return
}
connectOnceRef.current = true
connect(size.cols, size.rows)
}, [session?.active, connect])
useEffect(() => {
connectOnceRef.current = false
setExitInfo(null)
disconnect()
}, [sessionId, disconnect])
useEffect(() => {
return () => {
inputDisposableRef.current?.dispose()
connectOnceRef.current = false
disconnect()
}
}, [disconnect])
useEffect(() => {
if (session?.active === false) {
disconnect()
connectOnceRef.current = false
}
}, [session?.active, disconnect])
useEffect(() => {
if (terminalState.status === 'error') {
connectOnceRef.current = false
return
}
if (terminalState.status === 'connecting' || terminalState.status === 'connected') {
setExitInfo(null)
}
}, [terminalState.status])
if (!session) {
return (
<div className="flex h-full items-center justify-center">
<LoadingState label="Loading session…" className="text-sm" />
</div>
)
}
const subtitle = session.metadata?.path ?? sessionId
const status = terminalState.status
const errorMessage = terminalState.status === 'error' ? terminalState.error : null
return (
<div className="flex h-full flex-col">
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto w-full max-w-content flex items-center gap-2 p-3 border-b border-[var(--app-border)]">
<button
type="button"
onClick={goBack}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
>
<BackIcon />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">Terminal</div>
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
</div>
<ConnectionBadge status={status} />
</div>
</div>
{session.active ? null : (
<div className="px-3 pt-3">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
Session is inactive. Terminal is unavailable.
</div>
</div>
)}
{errorMessage ? (
<div className="mx-auto w-full max-w-content px-3 pt-3">
<div className="rounded-md border border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)] p-3 text-xs text-[var(--app-badge-error-text)]">
{errorMessage}
</div>
</div>
) : null}
{exitInfo ? (
<div className="mx-auto w-full max-w-content px-3 pt-3">
<div className="rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-xs text-[var(--app-hint)]">
Terminal exited{exitInfo.code !== null ? ` with code ${exitInfo.code}` : ''}{exitInfo.signal ? ` (${exitInfo.signal})` : ''}.
</div>
</div>
) : null}
<div className="flex-1 overflow-hidden bg-[var(--app-bg)]">
<div className="mx-auto h-full w-full max-w-content">
<TerminalView
onMount={handleTerminalMount}
onResize={handleResize}
className="h-full w-full"
/>
</div>
</div>
</div>
)
}