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 ?? ''}`}
/>
)
}