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
@@ -38,6 +38,26 @@ function SwitchToRemoteIcon() {
)
}
function TerminalIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="16" rx="2" ry="2" />
<polyline points="7 9 10 12 7 15" />
<line x1="12" y1="15" x2="17" y2="15" />
</svg>
)
}
function AbortIcon(props: { spinning: boolean }) {
if (props.spinning) {
return (
@@ -94,6 +114,9 @@ export function ComposerButtons(props: {
controlsDisabled: boolean
showSettingsButton: boolean
onSettingsToggle: () => void
showTerminalButton: boolean
terminalDisabled: boolean
onTerminal: () => void
showAbortButton: boolean
abortDisabled: boolean
isAborting: boolean
@@ -119,6 +142,19 @@ export function ComposerButtons(props: {
</button>
) : null}
{props.showTerminalButton ? (
<button
type="button"
aria-label="Terminal"
title="Terminal"
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={props.onTerminal}
disabled={props.terminalDisabled}
>
<TerminalIcon />
</button>
) : null}
{props.showAbortButton ? (
<button
type="button"
@@ -55,6 +55,7 @@ export function HappyComposer(props: {
onPermissionModeChange?: (mode: PermissionMode) => void
onModelModeChange?: (mode: ModelMode) => void
onSwitchToRemote?: () => void
onTerminal?: () => void
autocompletePrefixes?: string[]
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
}) {
@@ -70,6 +71,7 @@ export function HappyComposer(props: {
onPermissionModeChange,
onModelModeChange,
onSwitchToRemote,
onTerminal,
autocompletePrefixes = ['@', '/'],
autocompleteSuggestions = defaultSuggestionHandler
} = props
@@ -174,6 +176,7 @@ export function HappyComposer(props: {
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote)
const showTerminalButton = Boolean(onTerminal)
useEffect(() => {
if (!isAborting) return
@@ -477,6 +480,9 @@ export function HappyComposer(props: {
controlsDisabled={controlsDisabled}
showSettingsButton={showSettingsButton}
onSettingsToggle={handleSettingsToggle}
showTerminalButton={showTerminalButton}
terminalDisabled={controlsDisabled}
onTerminal={onTerminal ?? (() => {})}
showAbortButton={showAbortButton}
abortDisabled={abortDisabled}
isAborting={isAborting}
+8
View File
@@ -120,6 +120,13 @@ export function SessionChat(props: {
})
}, [navigate, props.session.id])
const handleViewTerminal = useCallback(() => {
navigate({
to: '/sessions/$sessionId/terminal',
params: { sessionId: props.session.id }
})
}, [navigate, props.session.id])
const runtime = useHappyRuntime({
session: props.session,
blocks: reconciled.blocks,
@@ -176,6 +183,7 @@ export function SessionChat(props: {
onPermissionModeChange={handlePermissionModeChange}
onModelModeChange={handleModelModeChange}
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active ? handleViewTerminal : undefined}
/>
</div>
</AssistantRuntimeProvider>
@@ -0,0 +1,83 @@
import { useEffect, useRef } 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'
function resolveThemeColors(): { background: string; foreground: string; selectionBackground: string } {
const styles = getComputedStyle(document.documentElement)
const background = styles.getPropertyValue('--app-bg').trim() || '#000000'
const foreground = styles.getPropertyValue('--app-fg').trim() || '#ffffff'
const selectionBackground = styles.getPropertyValue('--app-subtle-bg').trim() || 'rgba(255, 255, 255, 0.2)'
return { background, foreground, selectionBackground }
}
export function TerminalView(props: {
onMount?: (terminal: Terminal) => void
onResize?: (cols: number, rows: number) => void
className?: string
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const onMountRef = useRef(props.onMount)
const onResizeRef = useRef(props.onResize)
useEffect(() => {
onMountRef.current = props.onMount
}, [props.onMount])
useEffect(() => {
onResizeRef.current = props.onResize
}, [props.onResize])
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
const { background, foreground, selectionBackground } = resolveThemeColors()
const terminal = new Terminal({
cursorBlink: true,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
theme: {
background,
foreground,
cursor: foreground,
selectionBackground
},
convertEol: true
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(webLinksAddon)
terminal.open(container)
const resizeTerminal = () => {
fitAddon.fit()
onResizeRef.current?.(terminal.cols, terminal.rows)
}
const observer = new ResizeObserver(() => {
requestAnimationFrame(resizeTerminal)
})
observer.observe(container)
requestAnimationFrame(resizeTerminal)
onMountRef.current?.(terminal)
return () => {
observer.disconnect()
terminal.dispose()
}
}, [])
return (
<div
ref={containerRef}
className={`h-full w-full ${props.className ?? ''}`}
/>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
type TerminalConnectionState =
| { status: 'idle' }
| { status: 'connecting' }
| { status: 'connected' }
| { status: 'error'; error: string }
type UseTerminalSocketOptions = {
token: string
sessionId: string
terminalId: string
}
type TerminalReadyPayload = {
terminalId: string
}
type TerminalOutputPayload = {
terminalId: string
data: string
}
type TerminalExitPayload = {
terminalId: string
code: number | null
signal: string | null
}
type TerminalErrorPayload = {
terminalId: string
message: string
}
export function useTerminalSocket(options: UseTerminalSocketOptions): {
state: TerminalConnectionState
connect: (cols: number, rows: number) => void
write: (data: string) => void
resize: (cols: number, rows: number) => void
disconnect: () => void
onOutput: (handler: (data: string) => void) => void
onExit: (handler: (code: number | null, signal: string | null) => void) => void
} {
const [state, setState] = useState<TerminalConnectionState>({ status: 'idle' })
const socketRef = useRef<Socket | null>(null)
const outputHandlerRef = useRef<(data: string) => void>(() => {})
const exitHandlerRef = useRef<(code: number | null, signal: string | null) => void>(() => {})
const sessionIdRef = useRef(options.sessionId)
const terminalIdRef = useRef(options.terminalId)
const tokenRef = useRef(options.token)
const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null)
useEffect(() => {
sessionIdRef.current = options.sessionId
terminalIdRef.current = options.terminalId
}, [options.sessionId, options.terminalId])
useEffect(() => {
tokenRef.current = options.token
const socket = socketRef.current
if (!socket) {
return
}
if (!options.token) {
if (socket.connected) {
socket.disconnect()
}
return
}
socket.auth = { token: options.token }
if (socket.connected) {
socket.disconnect()
socket.connect()
}
}, [options.token])
const isCurrentTerminal = useCallback((terminalId: string) => terminalId === terminalIdRef.current, [])
const emitCreate = useCallback((socket: Socket, size: { cols: number; rows: number }) => {
socket.emit('terminal:create', {
sessionId: sessionIdRef.current,
terminalId: terminalIdRef.current,
cols: size.cols,
rows: size.rows
})
}, [])
const setErrorState = useCallback((message: string) => {
setState({ status: 'error', error: message })
}, [])
const connect = useCallback((cols: number, rows: number) => {
lastSizeRef.current = { cols, rows }
const token = tokenRef.current
const sessionId = sessionIdRef.current
const terminalId = terminalIdRef.current
if (!token || !sessionId || !terminalId) {
setErrorState('Missing terminal credentials.')
return
}
if (socketRef.current) {
const socket = socketRef.current
socket.auth = { token }
if (socket.connected) {
emitCreate(socket, { cols, rows })
} else {
socket.connect()
}
setState({ status: 'connecting' })
return
}
const socket = io('/terminal', {
auth: { token },
path: '/socket.io/',
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
transports: ['polling', 'websocket'],
autoConnect: false
})
socketRef.current = socket
setState({ status: 'connecting' })
socket.on('connect', () => {
const size = lastSizeRef.current ?? { cols, rows }
setState({ status: 'connecting' })
emitCreate(socket, size)
})
socket.on('terminal:ready', (payload: TerminalReadyPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
setState({ status: 'connected' })
})
socket.on('terminal:output', (payload: TerminalOutputPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
outputHandlerRef.current(payload.data)
})
socket.on('terminal:exit', (payload: TerminalExitPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
exitHandlerRef.current(payload.code, payload.signal)
setErrorState('Terminal exited.')
})
socket.on('terminal:error', (payload: TerminalErrorPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
setErrorState(payload.message)
})
socket.on('connect_error', (error) => {
const message = error instanceof Error ? error.message : 'Connection error'
setErrorState(message)
})
socket.on('disconnect', (reason) => {
if (reason === 'io client disconnect') {
setState({ status: 'idle' })
return
}
setErrorState(`Disconnected: ${reason}`)
})
socket.connect()
}, [emitCreate, setErrorState, isCurrentTerminal])
const write = useCallback((data: string) => {
const socket = socketRef.current
if (!socket || !socket.connected) {
return
}
socket.emit('terminal:write', { terminalId: terminalIdRef.current, data })
}, [])
const resize = useCallback((cols: number, rows: number) => {
lastSizeRef.current = { cols, rows }
const socket = socketRef.current
if (!socket || !socket.connected) {
return
}
socket.emit('terminal:resize', { terminalId: terminalIdRef.current, cols, rows })
}, [])
const disconnect = useCallback(() => {
const socket = socketRef.current
if (!socket) {
return
}
socket.removeAllListeners()
socket.disconnect()
socketRef.current = null
setState({ status: 'idle' })
}, [])
const onOutput = useCallback((handler: (data: string) => void) => {
outputHandlerRef.current = handler
}, [])
const onExit = useCallback((handler: (code: number | null, signal: string | null) => void) => {
exitHandlerRef.current = handler
}, [])
return {
state,
connect,
write,
resize,
disconnect,
onOutput,
onExit
}
}
+8
View File
@@ -24,6 +24,7 @@ import { useSendMessage } from '@/hooks/mutations/useSendMessage'
import { queryKeys } from '@/lib/query-keys'
import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file'
import TerminalPage from '@/routes/sessions/terminal'
function BackIcon(props: { className?: string }) {
return (
@@ -251,6 +252,12 @@ const sessionFilesRoute = createRoute({
component: FilesPage,
})
const sessionTerminalRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/terminal',
component: TerminalPage,
})
type SessionFileSearch = {
path: string
staged?: boolean
@@ -282,6 +289,7 @@ export const routeTree = rootRoute.addChildren([
indexRoute,
sessionsRoute,
sessionRoute,
sessionTerminalRoute,
sessionFilesRoute,
sessionFileRoute,
newSessionRoute,
+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>
)
}