mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user