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
+10
View File
@@ -87,6 +87,9 @@
"@tanstack/react-query": "^5.71.10",
"@tanstack/react-query-devtools": "^5.71.10",
"@tanstack/react-router": "^1.114.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^7.0.0",
@@ -96,6 +99,7 @@
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"shiki": "^3.20.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.5",
},
"devDependencies": {
@@ -669,6 +673,12 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
+1 -1
View File
@@ -43,7 +43,7 @@
"update-homebrew-formula": "bun run scripts/update-homebrew-formula.ts",
"test": "bun run tools:unpack && vitest run",
"test:win": "vitest run",
"dev": "tsx src/index.ts",
"dev": "bun src/index.ts",
"dev:local-server": "tsx --env-file .env.dev-local-server src/index.ts",
"dev:integration-test-env": "tsx --env-file .env.integration-test src/index.ts",
"release-all": "bun run scripts/release-all.ts"
+50
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { io, type Socket } from 'socket.io-client'
import type { ZodType } from 'zod'
import { logger } from '@/ui/logger'
import { backoff } from '@/utils/time'
import { AsyncLock } from '@/utils/lock'
@@ -10,6 +11,13 @@ import type { AgentState, ClientToServerEvents, MessageContent, MessageMeta, Met
import { AgentStateSchema, MetadataSchema, UserMessageSchema } from './types'
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
import { TerminalManager } from '@/terminal/TerminalManager'
import {
TerminalClosePayloadSchema,
TerminalOpenPayloadSchema,
TerminalResizePayloadSchema,
TerminalWritePayloadSchema
} from '@/terminal/types'
export class ApiSessionClient extends EventEmitter {
private readonly token: string
@@ -22,6 +30,7 @@ export class ApiSessionClient extends EventEmitter {
private pendingMessages: UserMessage[] = []
private pendingMessageCallback: ((message: UserMessage) => void) | null = null
readonly rpcHandlerManager: RpcHandlerManager
private readonly terminalManager: TerminalManager
private agentStateLock = new AsyncLock()
private metadataLock = new AsyncLock()
@@ -58,6 +67,15 @@ export class ApiSessionClient extends EventEmitter {
autoConnect: false
})
this.terminalManager = new TerminalManager({
sessionId: this.sessionId,
getSessionPath: () => this.metadata?.path ?? null,
onReady: (payload) => this.socket.emit('terminal:ready', payload),
onOutput: (payload) => this.socket.emit('terminal:output', payload),
onExit: (payload) => this.socket.emit('terminal:exit', payload),
onError: (payload) => this.socket.emit('terminal:error', payload)
})
this.socket.on('connect', () => {
logger.debug('Socket connected successfully')
this.rpcHandlerManager.onSocketConnect(this.socket)
@@ -70,6 +88,7 @@ export class ApiSessionClient extends EventEmitter {
this.socket.on('disconnect', (reason) => {
logger.debug('[API] Socket disconnected:', reason)
this.rpcHandlerManager.onSocketDisconnect()
this.terminalManager.closeAll()
})
this.socket.on('connect_error', (error) => {
@@ -77,6 +96,36 @@ export class ApiSessionClient extends EventEmitter {
this.rpcHandlerManager.onSocketDisconnect()
})
const handleTerminalEvent = <T extends { sessionId: string }>(
schema: ZodType<T>,
handler: (payload: T) => void
) => (data: unknown) => {
const parsed = schema.safeParse(data)
if (!parsed.success) {
return
}
if (parsed.data.sessionId !== this.sessionId) {
return
}
handler(parsed.data)
}
this.socket.on('terminal:open', handleTerminalEvent(TerminalOpenPayloadSchema, (payload) => {
this.terminalManager.create(payload.terminalId, payload.cols, payload.rows)
}))
this.socket.on('terminal:write', handleTerminalEvent(TerminalWritePayloadSchema, (payload) => {
this.terminalManager.write(payload.terminalId, payload.data)
}))
this.socket.on('terminal:resize', handleTerminalEvent(TerminalResizePayloadSchema, (payload) => {
this.terminalManager.resize(payload.terminalId, payload.cols, payload.rows)
}))
this.socket.on('terminal:close', handleTerminalEvent(TerminalClosePayloadSchema, (payload) => {
this.terminalManager.close(payload.terminalId)
}))
this.socket.on('update', (data: Update) => {
try {
if (!data.body) return
@@ -458,6 +507,7 @@ export class ApiSessionClient extends EventEmitter {
close(): void {
this.rpcHandlerManager.onSocketDisconnect()
this.terminalManager.closeAll()
this.socket.disconnect()
}
}
+18
View File
@@ -1,6 +1,16 @@
import { z } from 'zod'
import { UsageSchema } from '@/claude/types'
import type { PermissionMode } from '@/claude/loop'
import type {
TerminalClosePayload,
TerminalExitPayload,
TerminalOpenPayload,
TerminalOutputPayload,
TerminalReadyPayload,
TerminalResizePayload,
TerminalWritePayload,
TerminalErrorPayload
} from '@/terminal/types'
export type Usage = z.infer<typeof UsageSchema>
@@ -290,6 +300,10 @@ export type MessageContent = z.infer<typeof MessageContentSchema>
export interface ServerToClientEvents {
update: (data: Update) => void
'rpc-request': (data: { method: string; params: string }, callback: (response: string) => void) => void
'terminal:open': (data: TerminalOpenPayload) => void
'terminal:write': (data: TerminalWritePayload) => void
'terminal:resize': (data: TerminalResizePayload) => void
'terminal:close': (data: TerminalClosePayload) => void
error: (data: { message: string }) => void
}
@@ -344,6 +358,10 @@ export interface ClientToServerEvents {
}) => void) => void
'rpc-register': (data: { method: string }) => void
'rpc-unregister': (data: { method: string }) => void
'terminal:ready': (data: TerminalReadyPayload) => void
'terminal:output': (data: TerminalOutputPayload) => void
'terminal:exit': (data: TerminalExitPayload) => void
'terminal:error': (data: TerminalErrorPayload) => void
ping: (callback: () => void) => void
'usage-report': (data: unknown) => void
}
+270
View File
@@ -0,0 +1,270 @@
import { logger } from '@/ui/logger'
import type {
TerminalErrorPayload,
TerminalExitPayload,
TerminalOutputPayload,
TerminalReadyPayload,
TerminalSession
} from './types'
type TerminalRuntime = TerminalSession & {
proc: Bun.Subprocess
terminal: Bun.Terminal
idleTimer: ReturnType<typeof setTimeout> | null
}
type TerminalManagerOptions = {
sessionId: string
getSessionPath: () => string | null
onReady: (payload: TerminalReadyPayload) => void
onOutput: (payload: TerminalOutputPayload) => void
onExit: (payload: TerminalExitPayload) => void
onError: (payload: TerminalErrorPayload) => void
idleTimeoutMs?: number
maxTerminals?: number
}
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000
const DEFAULT_MAX_TERMINALS = 4
const SENSITIVE_ENV_KEYS = new Set([
'CLI_API_TOKEN',
'HAPI_SERVER_URL',
'HAPI_HTTP_MCP_URL',
'TELEGRAM_BOT_TOKEN',
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY'
])
function resolveEnvNumber(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) {
return fallback
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
function resolveShell(): string {
if (process.env.SHELL) {
return process.env.SHELL
}
if (process.platform === 'darwin') {
return '/bin/zsh'
}
return '/bin/bash'
}
function buildFilteredEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {
continue
}
if (SENSITIVE_ENV_KEYS.has(key)) {
continue
}
env[key] = value
}
return env
}
export class TerminalManager {
private readonly sessionId: string
private readonly getSessionPath: () => string | null
private readonly onReady: (payload: TerminalReadyPayload) => void
private readonly onOutput: (payload: TerminalOutputPayload) => void
private readonly onExit: (payload: TerminalExitPayload) => void
private readonly onError: (payload: TerminalErrorPayload) => void
private readonly idleTimeoutMs: number
private readonly maxTerminals: number
private readonly terminals: Map<string, TerminalRuntime> = new Map()
private readonly filteredEnv: NodeJS.ProcessEnv
constructor(options: TerminalManagerOptions) {
this.sessionId = options.sessionId
this.getSessionPath = options.getSessionPath
this.onReady = options.onReady
this.onOutput = options.onOutput
this.onExit = options.onExit
this.onError = options.onError
this.idleTimeoutMs = options.idleTimeoutMs ?? resolveEnvNumber('HAPI_TERMINAL_IDLE_TIMEOUT_MS', DEFAULT_IDLE_TIMEOUT_MS)
this.maxTerminals = options.maxTerminals ?? resolveEnvNumber('HAPI_TERMINAL_MAX_TERMINALS', DEFAULT_MAX_TERMINALS)
this.filteredEnv = buildFilteredEnv()
}
create(terminalId: string, cols: number, rows: number): void {
if (process.platform === 'win32') {
this.emitError(terminalId, 'Terminal is not supported on Windows.')
return
}
const existing = this.terminals.get(terminalId)
if (existing) {
existing.cols = cols
existing.rows = rows
existing.terminal.resize(cols, rows)
this.markActivity(existing)
this.onReady({ sessionId: this.sessionId, terminalId })
return
}
if (this.terminals.size >= this.maxTerminals) {
this.emitError(terminalId, `Too many terminals open (max ${this.maxTerminals}).`)
return
}
if (typeof Bun === 'undefined' || typeof Bun.spawn !== 'function') {
this.emitError(terminalId, 'Terminal is unavailable in this runtime.')
return
}
const sessionPath = this.getSessionPath() ?? process.cwd()
const shell = resolveShell()
const decoder = new TextDecoder()
try {
const proc = Bun.spawn([shell], {
cwd: sessionPath,
env: this.filteredEnv,
terminal: {
cols,
rows,
data: (terminal, data) => {
const text = decoder.decode(data, { stream: true })
if (text) {
this.onOutput({ sessionId: this.sessionId, terminalId, data: text })
}
const active = this.terminals.get(terminalId)
if (active) {
this.markActivity(active)
}
},
exit: (terminal, exitCode) => {
if (exitCode === 1) {
this.emitError(terminalId, 'Terminal stream closed unexpectedly.')
}
}
},
onExit: (subprocess, exitCode) => {
const signal = subprocess.signalCode ?? null
this.onExit({
sessionId: this.sessionId,
terminalId,
code: exitCode ?? null,
signal
})
this.cleanup(terminalId)
}
})
const terminal = proc.terminal
if (!terminal) {
try {
proc.kill()
} catch (error) {
logger.debug('[TERMINAL] Failed to kill process after missing terminal', { error })
}
this.emitError(terminalId, 'Failed to attach terminal.')
return
}
const runtime: TerminalRuntime = {
terminalId,
cols,
rows,
proc,
terminal,
idleTimer: null
}
this.terminals.set(terminalId, runtime)
this.markActivity(runtime)
this.onReady({ sessionId: this.sessionId, terminalId })
} catch (error) {
logger.debug('[TERMINAL] Failed to spawn terminal', { error })
this.emitError(terminalId, 'Failed to spawn terminal.')
}
}
write(terminalId: string, data: string): void {
const runtime = this.terminals.get(terminalId)
if (!runtime) {
this.emitError(terminalId, 'Terminal not found.')
return
}
runtime.terminal.write(data)
this.markActivity(runtime)
}
resize(terminalId: string, cols: number, rows: number): void {
const runtime = this.terminals.get(terminalId)
if (!runtime) {
return
}
runtime.cols = cols
runtime.rows = rows
runtime.terminal.resize(cols, rows)
this.markActivity(runtime)
}
close(terminalId: string): void {
this.cleanup(terminalId)
}
closeAll(): void {
for (const terminalId of this.terminals.keys()) {
this.cleanup(terminalId)
}
}
private markActivity(runtime: TerminalRuntime): void {
this.scheduleIdleTimer(runtime)
}
private scheduleIdleTimer(runtime: TerminalRuntime): void {
if (this.idleTimeoutMs <= 0) {
return
}
if (runtime.idleTimer) {
clearTimeout(runtime.idleTimer)
}
runtime.idleTimer = setTimeout(() => {
this.emitError(runtime.terminalId, 'Terminal closed due to inactivity.')
this.cleanup(runtime.terminalId)
}, this.idleTimeoutMs)
}
private cleanup(terminalId: string): void {
const runtime = this.terminals.get(terminalId)
if (!runtime) {
return
}
this.terminals.delete(terminalId)
if (runtime.idleTimer) {
clearTimeout(runtime.idleTimer)
}
if (!runtime.proc.killed && runtime.proc.exitCode === null) {
try {
runtime.proc.kill()
} catch (error) {
logger.debug('[TERMINAL] Failed to kill process', { error })
}
}
try {
runtime.terminal.close()
} catch (error) {
logger.debug('[TERMINAL] Failed to close terminal', { error })
}
}
private emitError(terminalId: string, message: string): void {
this.onError({ sessionId: this.sessionId, terminalId, message })
}
}
+72
View File
@@ -0,0 +1,72 @@
import { z } from 'zod'
export type TerminalSession = {
terminalId: string
cols: number
rows: number
}
export const TerminalOpenPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
export type TerminalOpenPayload = z.infer<typeof TerminalOpenPayloadSchema>
export const TerminalWritePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
data: z.string()
})
export type TerminalWritePayload = z.infer<typeof TerminalWritePayloadSchema>
export const TerminalResizePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
export type TerminalResizePayload = z.infer<typeof TerminalResizePayloadSchema>
export const TerminalClosePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1)
})
export type TerminalClosePayload = z.infer<typeof TerminalClosePayloadSchema>
export const TerminalReadyPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1)
})
export type TerminalReadyPayload = z.infer<typeof TerminalReadyPayloadSchema>
export const TerminalOutputPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
data: z.string()
})
export type TerminalOutputPayload = z.infer<typeof TerminalOutputPayloadSchema>
export const TerminalExitPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
code: z.number().int().nullable(),
signal: z.string().nullable()
})
export type TerminalExitPayload = z.infer<typeof TerminalExitPayloadSchema>
export const TerminalErrorPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
message: z.string()
})
export type TerminalErrorPayload = z.infer<typeof TerminalErrorPayloadSchema>
+3 -1
View File
@@ -16,7 +16,9 @@
"typecheck:cli": "cd cli && bun run typecheck",
"typecheck:server": "cd server && bun run typecheck",
"typecheck:web": "cd web && bun run typecheck",
"test": "cd cli && bun run test",
"test": "bun run test:cli && bun run test:server",
"test:cli": "cd cli && bun run test",
"test:server": "cd server && bun run test",
"clean-session": "bun run server/scripts/cleanup-sessions.ts",
"release-all": "cd cli && bun run release-all"
},
+1
View File
@@ -9,6 +9,7 @@
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --watch run src/index.ts",
"test": "bun test",
"typecheck": "tsc --noEmit",
"build": "bun build src/index.ts --outdir dist --target bun",
"generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts"
+2
View File
@@ -84,6 +84,8 @@ async function main() {
const socketServer = createSocketServer({
store,
jwtSecret,
getSession: (sessionId) => syncEngine?.getSession(sessionId) ?? store.getSession(sessionId),
onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event),
onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload),
onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload),
+97 -1
View File
@@ -5,6 +5,7 @@ import type { Store } from '../../store'
import { RpcRegistry } from '../rpcRegistry'
import type { SyncEvent } from '../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../sync/todos'
import { TerminalRegistry } from '../terminalRegistry'
type SessionAlivePayload = {
sid: string
@@ -61,10 +62,35 @@ const machineUpdateStateSchema = z.object({
daemonState: z.unknown().nullable()
})
const terminalReadySchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1)
})
const terminalOutputSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
data: z.string()
})
const terminalExitSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
code: z.number().int().nullable(),
signal: z.string().nullable()
})
const terminalErrorSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
message: z.string()
})
export type CliHandlersDeps = {
io: Server
store: Store
rpcRegistry: RpcRegistry
terminalRegistry: TerminalRegistry
onSessionAlive?: (payload: SessionAlivePayload) => void
onSessionEnd?: (payload: SessionEndPayload) => void
onMachineAlive?: (payload: MachineAlivePayload) => void
@@ -72,7 +98,8 @@ export type CliHandlersDeps = {
}
export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void {
const { io, store, rpcRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps
const terminalNamespace = io.of('/terminal')
const auth = socket.handshake.auth as Record<string, unknown> | undefined
const sessionId = typeof auth?.sessionId === 'string' ? auth.sessionId : null
@@ -103,6 +130,14 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void
socket.on('disconnect', () => {
rpcRegistry.unregisterAll(socket)
const removed = terminalRegistry.removeByCliSocket(socket.id)
for (const entry of removed) {
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
terminalSocket?.emit('terminal:error', {
terminalId: entry.terminalId,
message: 'CLI disconnected.'
})
}
})
socket.on('message', (data: unknown) => {
@@ -332,4 +367,65 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void
socket.on('ping', (callback: () => void) => {
callback()
})
const forwardTerminalEvent = (event: string, payload: { sessionId: string; terminalId: string } & Record<string, unknown>) => {
const entry = terminalRegistry.get(payload.terminalId)
if (!entry) {
return
}
if (entry.cliSocketId !== socket.id) {
return
}
if (payload.sessionId !== entry.sessionId) {
return
}
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
if (!terminalSocket) {
return
}
terminalSocket.emit(event, payload)
}
socket.on('terminal:ready', (data: unknown) => {
const parsed = terminalReadySchema.safeParse(data)
if (!parsed.success) {
return
}
terminalRegistry.markActivity(parsed.data.terminalId)
forwardTerminalEvent('terminal:ready', parsed.data)
})
socket.on('terminal:output', (data: unknown) => {
const parsed = terminalOutputSchema.safeParse(data)
if (!parsed.success) {
return
}
terminalRegistry.markActivity(parsed.data.terminalId)
forwardTerminalEvent('terminal:output', parsed.data)
})
socket.on('terminal:exit', (data: unknown) => {
const parsed = terminalExitSchema.safeParse(data)
if (!parsed.success) {
return
}
const entry = terminalRegistry.get(parsed.data.terminalId)
if (!entry || entry.sessionId !== parsed.data.sessionId || entry.cliSocketId !== socket.id) {
return
}
terminalRegistry.remove(parsed.data.terminalId)
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
if (!terminalSocket) {
return
}
terminalSocket.emit('terminal:exit', parsed.data)
})
socket.on('terminal:error', (data: unknown) => {
const parsed = terminalErrorSchema.safeParse(data)
if (!parsed.success) {
return
}
forwardTerminalEvent('terminal:error', parsed.data)
})
}
+226
View File
@@ -0,0 +1,226 @@
import { describe, expect, it } from 'bun:test'
import type { Server, Socket } from 'socket.io'
import { registerTerminalHandlers } from './terminal'
import { TerminalRegistry } from '../terminalRegistry'
type EmittedEvent = {
event: string
data: unknown
}
class FakeSocket {
readonly id: string
readonly data: Record<string, unknown> = {}
readonly emitted: EmittedEvent[] = []
private readonly handlers = new Map<string, (...args: unknown[]) => void>()
constructor(id: string) {
this.id = id
}
on(event: string, handler: (...args: unknown[]) => void): this {
this.handlers.set(event, handler)
return this
}
emit(event: string, data: unknown): boolean {
this.emitted.push({ event, data })
return true
}
trigger(event: string, data?: unknown): void {
const handler = this.handlers.get(event)
if (!handler) {
return
}
if (typeof data === 'undefined') {
handler()
return
}
handler(data)
}
}
class FakeNamespace {
readonly sockets = new Map<string, FakeSocket>()
readonly adapter = { rooms: new Map<string, Set<string>>() }
}
class FakeServer {
private readonly namespaces = new Map<string, FakeNamespace>()
of(name: string): FakeNamespace {
const existing = this.namespaces.get(name)
if (existing) {
return existing
}
const namespace = new FakeNamespace()
this.namespaces.set(name, namespace)
return namespace
}
}
type Harness = {
io: FakeServer
terminalSocket: FakeSocket
cliNamespace: FakeNamespace
terminalRegistry: TerminalRegistry
}
function createHarness(options?: {
sessionActive?: boolean
maxTerminalsPerSocket?: number
maxTerminalsPerSession?: number
}): Harness {
const io = new FakeServer()
const terminalSocket = new FakeSocket('terminal-socket')
const terminalRegistry = new TerminalRegistry({ idleTimeoutMs: 0 })
const cliNamespace = io.of('/cli')
registerTerminalHandlers(terminalSocket as unknown as Socket, {
io: io as unknown as Server,
getSession: () => ({ active: options?.sessionActive ?? true }),
terminalRegistry,
maxTerminalsPerSocket: options?.maxTerminalsPerSocket ?? 4,
maxTerminalsPerSession: options?.maxTerminalsPerSession ?? 4
})
return { io, terminalSocket, cliNamespace, terminalRegistry }
}
function connectCliSocket(cliNamespace: FakeNamespace, cliSocket: FakeSocket, sessionId: string): void {
cliNamespace.sockets.set(cliSocket.id, cliSocket)
const roomId = `session:${sessionId}`
const room = cliNamespace.adapter.rooms.get(roomId) ?? new Set<string>()
room.add(cliSocket.id)
cliNamespace.adapter.rooms.set(roomId, room)
}
function lastEmit(socket: FakeSocket, event: string): EmittedEvent | undefined {
return [...socket.emitted].reverse().find((entry) => entry.event === event)
}
describe('terminal socket handlers', () => {
it('rejects terminal creation when session is inactive', () => {
const { terminalSocket, terminalRegistry } = createHarness({ sessionActive: false })
terminalSocket.trigger('terminal:create', {
sessionId: 'session-1',
terminalId: 'terminal-1',
cols: 80,
rows: 24
})
const errorEvent = lastEmit(terminalSocket, 'terminal:error')
expect(errorEvent).toBeDefined()
expect(errorEvent?.data).toEqual({
terminalId: 'terminal-1',
message: 'Session is inactive or unavailable.'
})
expect(terminalRegistry.get('terminal-1')).toBeNull()
})
it('opens a terminal and forwards write/resize/close to the CLI socket', () => {
const { terminalSocket, cliNamespace, terminalRegistry } = createHarness()
const cliSocket = new FakeSocket('cli-socket-1')
connectCliSocket(cliNamespace, cliSocket, 'session-1')
terminalSocket.trigger('terminal:create', {
sessionId: 'session-1',
terminalId: 'terminal-1',
cols: 120,
rows: 40
})
const openEvent = lastEmit(cliSocket, 'terminal:open')
expect(openEvent?.data).toEqual({
sessionId: 'session-1',
terminalId: 'terminal-1',
cols: 120,
rows: 40
})
expect(terminalRegistry.get('terminal-1')).not.toBeNull()
terminalSocket.trigger('terminal:write', {
terminalId: 'terminal-1',
data: 'ls\n'
})
const writeEvent = lastEmit(cliSocket, 'terminal:write')
expect(writeEvent?.data).toEqual({
sessionId: 'session-1',
terminalId: 'terminal-1',
data: 'ls\n'
})
terminalSocket.trigger('terminal:resize', {
terminalId: 'terminal-1',
cols: 100,
rows: 30
})
const resizeEvent = lastEmit(cliSocket, 'terminal:resize')
expect(resizeEvent?.data).toEqual({
sessionId: 'session-1',
terminalId: 'terminal-1',
cols: 100,
rows: 30
})
terminalSocket.trigger('terminal:close', {
terminalId: 'terminal-1'
})
const closeEvent = lastEmit(cliSocket, 'terminal:close')
expect(closeEvent?.data).toEqual({
sessionId: 'session-1',
terminalId: 'terminal-1'
})
expect(terminalRegistry.get('terminal-1')).toBeNull()
})
it('cleans up and notifies CLI on terminal socket disconnect', () => {
const { terminalSocket, cliNamespace, terminalRegistry } = createHarness()
const cliSocket = new FakeSocket('cli-socket-1')
connectCliSocket(cliNamespace, cliSocket, 'session-1')
terminalSocket.trigger('terminal:create', {
sessionId: 'session-1',
terminalId: 'terminal-1',
cols: 90,
rows: 24
})
terminalSocket.trigger('disconnect')
const closeEvent = lastEmit(cliSocket, 'terminal:close')
expect(closeEvent?.data).toEqual({
sessionId: 'session-1',
terminalId: 'terminal-1'
})
expect(terminalRegistry.get('terminal-1')).toBeNull()
})
it('enforces per-socket terminal limits', () => {
const { terminalSocket, cliNamespace } = createHarness({ maxTerminalsPerSocket: 1 })
const cliSocket = new FakeSocket('cli-socket-1')
connectCliSocket(cliNamespace, cliSocket, 'session-1')
terminalSocket.trigger('terminal:create', {
sessionId: 'session-1',
terminalId: 'terminal-1',
cols: 80,
rows: 24
})
terminalSocket.trigger('terminal:create', {
sessionId: 'session-1',
terminalId: 'terminal-2',
cols: 80,
rows: 24
})
const errorEvent = lastEmit(terminalSocket, 'terminal:error')
expect(errorEvent?.data).toEqual({
terminalId: 'terminal-2',
message: 'Too many terminals open (max 1).'
})
})
})
+209
View File
@@ -0,0 +1,209 @@
import type { Server, Socket } from 'socket.io'
import { z } from 'zod'
import type { TerminalRegistry, TerminalRegistryEntry } from '../terminalRegistry'
const terminalCreateSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
const terminalWriteSchema = z.object({
terminalId: z.string().min(1),
data: z.string()
})
const terminalResizeSchema = z.object({
terminalId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
const terminalCloseSchema = z.object({
terminalId: z.string().min(1)
})
export type TerminalHandlersDeps = {
io: Server
getSession: (sessionId: string) => { active: boolean } | null
terminalRegistry: TerminalRegistry
maxTerminalsPerSocket: number
maxTerminalsPerSession: number
}
export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersDeps): void {
const { io, getSession, terminalRegistry, maxTerminalsPerSocket, maxTerminalsPerSession } = deps
const cliNamespace = io.of('/cli')
const emitTerminalError = (terminalId: string, message: string) => {
socket.emit('terminal:error', { terminalId, message })
}
const resolveEntryForSocket = (terminalId: string): TerminalRegistryEntry | null => {
const entry = terminalRegistry.get(terminalId)
if (!entry || entry.socketId !== socket.id) {
return null
}
return entry
}
const resolveCliSocket = (entry: TerminalRegistryEntry, reportError: boolean): Socket | null => {
const cliSocket = cliNamespace.sockets.get(entry.cliSocketId)
if (!cliSocket) {
terminalRegistry.remove(entry.terminalId)
if (reportError) {
emitTerminalError(entry.terminalId, 'CLI disconnected.')
}
return null
}
return cliSocket
}
const emitCloseToCli = (entry: TerminalRegistryEntry): void => {
const cliSocket = cliNamespace.sockets.get(entry.cliSocketId)
if (!cliSocket) {
return
}
cliSocket.emit('terminal:close', {
sessionId: entry.sessionId,
terminalId: entry.terminalId
})
}
const pickCliSocketId = (sessionId: string): string | null => {
const room = cliNamespace.adapter.rooms.get(`session:${sessionId}`)
if (!room || room.size === 0) {
return null
}
for (const socketId of room) {
if (cliNamespace.sockets.has(socketId)) {
return socketId
}
}
return null
}
socket.on('terminal:create', (data: unknown) => {
const parsed = terminalCreateSchema.safeParse(data)
if (!parsed.success) {
return
}
const { sessionId, terminalId, cols, rows } = parsed.data
const session = getSession(sessionId)
if (!session || !session.active) {
emitTerminalError(terminalId, 'Session is inactive or unavailable.')
return
}
if (terminalRegistry.countForSocket(socket.id) >= maxTerminalsPerSocket) {
emitTerminalError(terminalId, `Too many terminals open (max ${maxTerminalsPerSocket}).`)
return
}
if (terminalRegistry.countForSession(sessionId) >= maxTerminalsPerSession) {
emitTerminalError(terminalId, `Too many terminals open for this session (max ${maxTerminalsPerSession}).`)
return
}
const cliSocketId = pickCliSocketId(sessionId)
if (!cliSocketId) {
emitTerminalError(terminalId, 'CLI is not connected for this session.')
return
}
const entry = terminalRegistry.register(terminalId, sessionId, socket.id, cliSocketId)
if (!entry) {
emitTerminalError(terminalId, 'Terminal ID is already in use.')
return
}
const cliSocket = cliNamespace.sockets.get(cliSocketId)
if (!cliSocket) {
terminalRegistry.remove(terminalId)
emitTerminalError(terminalId, 'CLI is not connected for this session.')
return
}
cliSocket.emit('terminal:open', {
sessionId,
terminalId,
cols,
rows
})
terminalRegistry.markActivity(terminalId)
})
socket.on('terminal:write', (data: unknown) => {
const parsed = terminalWriteSchema.safeParse(data)
if (!parsed.success) {
return
}
const { terminalId, data: payload } = parsed.data
const entry = resolveEntryForSocket(terminalId)
if (!entry) {
return
}
const cliSocket = resolveCliSocket(entry, true)
if (!cliSocket) {
return
}
cliSocket.emit('terminal:write', {
sessionId: entry.sessionId,
terminalId,
data: payload
})
terminalRegistry.markActivity(terminalId)
})
socket.on('terminal:resize', (data: unknown) => {
const parsed = terminalResizeSchema.safeParse(data)
if (!parsed.success) {
return
}
const { terminalId, cols, rows } = parsed.data
const entry = resolveEntryForSocket(terminalId)
if (!entry) {
return
}
const cliSocket = resolveCliSocket(entry, true)
if (!cliSocket) {
return
}
cliSocket.emit('terminal:resize', {
sessionId: entry.sessionId,
terminalId,
cols,
rows
})
terminalRegistry.markActivity(terminalId)
})
socket.on('terminal:close', (data: unknown) => {
const parsed = terminalCloseSchema.safeParse(data)
if (!parsed.success) {
return
}
const { terminalId } = parsed.data
const entry = resolveEntryForSocket(terminalId)
if (!entry) {
return
}
terminalRegistry.remove(terminalId)
emitCloseToCli(entry)
})
socket.on('disconnect', () => {
const removed = terminalRegistry.removeBySocket(socket.id)
for (const entry of removed) {
emitCloseToCli(entry)
}
})
}
+72 -1
View File
@@ -1,13 +1,35 @@
import { Server as Engine } from '@socket.io/bun-engine'
import { Server } from 'socket.io'
import { jwtVerify } from 'jose'
import { z } from 'zod'
import type { Store } from '../store'
import { configuration } from '../configuration'
import { registerCliHandlers } from './handlers/cli'
import { registerTerminalHandlers } from './handlers/terminal'
import { RpcRegistry } from './rpcRegistry'
import type { SyncEvent } from '../sync/syncEngine'
import { TerminalRegistry } from './terminalRegistry'
const jwtPayloadSchema = z.object({
uid: z.number()
})
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000
const DEFAULT_MAX_TERMINALS = 4
function resolveEnvNumber(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) {
return fallback
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
export type SocketServerDeps = {
store: Store
jwtSecret: Uint8Array
getSession?: (sessionId: string) => { active: boolean } | null
onWebappEvent?: (event: SyncEvent) => void
onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void
onSessionEnd?: (payload: { sid: string; time: number }) => void
@@ -46,8 +68,28 @@ export function createSocketServer(deps: SocketServerDeps): {
io.bind(engine)
const rpcRegistry = new RpcRegistry()
const idleTimeoutMs = resolveEnvNumber('HAPI_TERMINAL_IDLE_TIMEOUT_MS', DEFAULT_IDLE_TIMEOUT_MS)
const maxTerminals = resolveEnvNumber('HAPI_TERMINAL_MAX_TERMINALS', DEFAULT_MAX_TERMINALS)
const maxTerminalsPerSocket = maxTerminals
const maxTerminalsPerSession = maxTerminals
const cliNs = io.of('/cli')
const terminalNs = io.of('/terminal')
const terminalRegistry = new TerminalRegistry({
idleTimeoutMs,
onIdle: (entry) => {
const terminalSocket = terminalNs.sockets.get(entry.socketId)
terminalSocket?.emit('terminal:error', {
terminalId: entry.terminalId,
message: 'Terminal closed due to inactivity.'
})
const cliSocket = cliNs.sockets.get(entry.cliSocketId)
cliSocket?.emit('terminal:close', {
sessionId: entry.sessionId,
terminalId: entry.terminalId
})
}
})
cliNs.use((socket, next) => {
const auth = socket.handshake.auth as Record<string, unknown> | undefined
const token = typeof auth?.token === 'string' ? auth.token : null
@@ -60,11 +102,40 @@ export function createSocketServer(deps: SocketServerDeps): {
io,
store: deps.store,
rpcRegistry,
terminalRegistry,
onSessionAlive: deps.onSessionAlive,
onSessionEnd: deps.onSessionEnd,
onMachineAlive: deps.onMachineAlive,
onWebappEvent: deps.onWebappEvent
}))
terminalNs.use(async (socket, next) => {
const auth = socket.handshake.auth as Record<string, unknown> | undefined
const token = typeof auth?.token === 'string' ? auth.token : null
if (!token) {
return next(new Error('Missing token'))
}
try {
const verified = await jwtVerify(token, deps.jwtSecret, { algorithms: ['HS256'] })
const parsed = jwtPayloadSchema.safeParse(verified.payload)
if (!parsed.success) {
return next(new Error('Invalid token payload'))
}
socket.data.userId = parsed.data.uid
next()
return
} catch {
return next(new Error('Invalid token'))
}
})
terminalNs.on('connection', (socket) => registerTerminalHandlers(socket, {
io,
getSession: (sessionId) => deps.getSession?.(sessionId) ?? deps.store.getSession(sessionId),
terminalRegistry,
maxTerminalsPerSocket,
maxTerminalsPerSession
}))
return { io, engine, rpcRegistry }
}
+140
View File
@@ -0,0 +1,140 @@
export type TerminalRegistryEntry = {
terminalId: string
sessionId: string
socketId: string
cliSocketId: string
idleTimer: ReturnType<typeof setTimeout> | null
}
type TerminalRegistryOptions = {
idleTimeoutMs: number
onIdle?: (entry: TerminalRegistryEntry) => void
}
export class TerminalRegistry {
private readonly terminals = new Map<string, TerminalRegistryEntry>()
private readonly terminalsBySocket = new Map<string, Set<string>>()
private readonly terminalsBySession = new Map<string, Set<string>>()
private readonly terminalsByCliSocket = new Map<string, Set<string>>()
private readonly idleTimeoutMs: number
private readonly onIdle?: (entry: TerminalRegistryEntry) => void
constructor(options: TerminalRegistryOptions) {
this.idleTimeoutMs = options.idleTimeoutMs
this.onIdle = options.onIdle
}
register(terminalId: string, sessionId: string, socketId: string, cliSocketId: string): TerminalRegistryEntry | null {
if (this.terminals.has(terminalId)) {
return null
}
const entry: TerminalRegistryEntry = {
terminalId,
sessionId,
socketId,
cliSocketId,
idleTimer: null
}
this.terminals.set(terminalId, entry)
this.addToIndex(this.terminalsBySocket, socketId, terminalId)
this.addToIndex(this.terminalsBySession, sessionId, terminalId)
this.addToIndex(this.terminalsByCliSocket, cliSocketId, terminalId)
this.scheduleIdle(entry)
return entry
}
markActivity(terminalId: string): void {
const entry = this.terminals.get(terminalId)
if (!entry) {
return
}
this.scheduleIdle(entry)
}
get(terminalId: string): TerminalRegistryEntry | null {
return this.terminals.get(terminalId) ?? null
}
remove(terminalId: string): TerminalRegistryEntry | null {
const entry = this.terminals.get(terminalId)
if (!entry) {
return null
}
this.terminals.delete(terminalId)
this.removeFromIndex(this.terminalsBySocket, entry.socketId, terminalId)
this.removeFromIndex(this.terminalsBySession, entry.sessionId, terminalId)
this.removeFromIndex(this.terminalsByCliSocket, entry.cliSocketId, terminalId)
if (entry.idleTimer) {
clearTimeout(entry.idleTimer)
}
return entry
}
removeBySocket(socketId: string): TerminalRegistryEntry[] {
const ids = this.terminalsBySocket.get(socketId)
if (!ids || ids.size === 0) {
return []
}
return Array.from(ids).map((terminalId) => this.remove(terminalId)).filter(Boolean) as TerminalRegistryEntry[]
}
removeByCliSocket(socketId: string): TerminalRegistryEntry[] {
const ids = this.terminalsByCliSocket.get(socketId)
if (!ids || ids.size === 0) {
return []
}
return Array.from(ids).map((terminalId) => this.remove(terminalId)).filter(Boolean) as TerminalRegistryEntry[]
}
countForSocket(socketId: string): number {
return this.terminalsBySocket.get(socketId)?.size ?? 0
}
countForSession(sessionId: string): number {
return this.terminalsBySession.get(sessionId)?.size ?? 0
}
private scheduleIdle(entry: TerminalRegistryEntry): void {
if (this.idleTimeoutMs <= 0) {
return
}
if (entry.idleTimer) {
clearTimeout(entry.idleTimer)
}
entry.idleTimer = setTimeout(() => {
const current = this.terminals.get(entry.terminalId)
if (!current) {
return
}
this.onIdle?.(current)
this.remove(entry.terminalId)
}, this.idleTimeoutMs)
}
private addToIndex(index: Map<string, Set<string>>, key: string, terminalId: string): void {
const set = index.get(key)
if (set) {
set.add(terminalId)
} else {
index.set(key, new Set([terminalId]))
}
}
private removeFromIndex(index: Map<string, Set<string>>, key: string, terminalId: string): void {
const set = index.get(key)
if (!set) {
return
}
set.delete(terminalId)
if (set.size === 0) {
index.delete(key)
}
}
}
+4
View File
@@ -14,6 +14,9 @@
"@assistant-ui/react-markdown": "^0.11.8",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"@shikijs/langs": "^3.20.0",
"@shikijs/themes": "^3.20.0",
"@tanstack/react-query": "^5.71.10",
@@ -28,6 +31,7 @@
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"shiki": "^3.20.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
@@ -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>
)
}