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