mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +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:
@@ -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}
|
||||
|
||||
@@ -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 ?? ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user