From 18e631045129d6843748e00334bbba295be0d1e7 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 25 Dec 2025 21:39:23 +0800 Subject: [PATCH] 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 --- bun.lock | 10 + cli/package.json | 2 +- cli/src/api/apiSession.ts | 50 ++++ cli/src/api/types.ts | 18 ++ cli/src/terminal/TerminalManager.ts | 270 ++++++++++++++++++ cli/src/terminal/types.ts | 72 +++++ package.json | 4 +- server/package.json | 1 + server/src/index.ts | 2 + server/src/socket/handlers/cli.ts | 98 ++++++- server/src/socket/handlers/terminal.test.ts | 226 +++++++++++++++ server/src/socket/handlers/terminal.ts | 209 ++++++++++++++ server/src/socket/server.ts | 73 ++++- server/src/socket/terminalRegistry.ts | 140 +++++++++ web/package.json | 4 + .../AssistantChat/ComposerButtons.tsx | 36 +++ .../AssistantChat/HappyComposer.tsx | 6 + web/src/components/SessionChat.tsx | 8 + web/src/components/Terminal/TerminalView.tsx | 83 ++++++ web/src/hooks/useTerminalSocket.ts | 226 +++++++++++++++ web/src/router.tsx | 8 + web/src/routes/sessions/terminal.tsx | 221 ++++++++++++++ 22 files changed, 1763 insertions(+), 4 deletions(-) create mode 100644 cli/src/terminal/TerminalManager.ts create mode 100644 cli/src/terminal/types.ts create mode 100644 server/src/socket/handlers/terminal.test.ts create mode 100644 server/src/socket/handlers/terminal.ts create mode 100644 server/src/socket/terminalRegistry.ts create mode 100644 web/src/components/Terminal/TerminalView.tsx create mode 100644 web/src/hooks/useTerminalSocket.ts create mode 100644 web/src/routes/sessions/terminal.tsx diff --git a/bun.lock b/bun.lock index 5efe0f0d..82d2ada4 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/cli/package.json b/cli/package.json index f34e4c5d..c1abc304 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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" diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 4df03246..50422373 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -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 = ( + schema: ZodType, + 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() } } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index b884ee1c..68a43073 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -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 @@ -290,6 +300,10 @@ export type MessageContent = z.infer 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 } diff --git a/cli/src/terminal/TerminalManager.ts b/cli/src/terminal/TerminalManager.ts new file mode 100644 index 00000000..2f222ae4 --- /dev/null +++ b/cli/src/terminal/TerminalManager.ts @@ -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 | 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 = 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 }) + } +} diff --git a/cli/src/terminal/types.ts b/cli/src/terminal/types.ts new file mode 100644 index 00000000..3d4dd585 --- /dev/null +++ b/cli/src/terminal/types.ts @@ -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 + +export const TerminalWritePayloadSchema = z.object({ + sessionId: z.string().min(1), + terminalId: z.string().min(1), + data: z.string() +}) + +export type TerminalWritePayload = z.infer + +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 + +export const TerminalClosePayloadSchema = z.object({ + sessionId: z.string().min(1), + terminalId: z.string().min(1) +}) + +export type TerminalClosePayload = z.infer + +export const TerminalReadyPayloadSchema = z.object({ + sessionId: z.string().min(1), + terminalId: z.string().min(1) +}) + +export type TerminalReadyPayload = z.infer + +export const TerminalOutputPayloadSchema = z.object({ + sessionId: z.string().min(1), + terminalId: z.string().min(1), + data: z.string() +}) + +export type TerminalOutputPayload = z.infer + +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 + +export const TerminalErrorPayloadSchema = z.object({ + sessionId: z.string().min(1), + terminalId: z.string().min(1), + message: z.string() +}) + +export type TerminalErrorPayload = z.infer diff --git a/package.json b/package.json index 7c032f39..5e6a025e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/server/package.json b/server/package.json index faf813fd..5f3c9eb2 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/index.ts b/server/src/index.ts index 3d8a2c35..d1d35c77 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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), diff --git a/server/src/socket/handlers/cli.ts b/server/src/socket/handlers/cli.ts index 60226d40..13472204 100644 --- a/server/src/socket/handlers/cli.ts +++ b/server/src/socket/handlers/cli.ts @@ -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 | 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) => { + 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) + }) } diff --git a/server/src/socket/handlers/terminal.test.ts b/server/src/socket/handlers/terminal.test.ts new file mode 100644 index 00000000..e3038428 --- /dev/null +++ b/server/src/socket/handlers/terminal.test.ts @@ -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 = {} + readonly emitted: EmittedEvent[] = [] + private readonly handlers = new Map 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() + readonly adapter = { rooms: new Map>() } +} + +class FakeServer { + private readonly namespaces = new Map() + + 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() + 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).' + }) + }) +}) diff --git a/server/src/socket/handlers/terminal.ts b/server/src/socket/handlers/terminal.ts new file mode 100644 index 00000000..393a5461 --- /dev/null +++ b/server/src/socket/handlers/terminal.ts @@ -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) + } + }) +} diff --git a/server/src/socket/server.ts b/server/src/socket/server.ts index 58dc07c7..d9f13381 100644 --- a/server/src/socket/server.ts +++ b/server/src/socket/server.ts @@ -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 | 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 | 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 } } diff --git a/server/src/socket/terminalRegistry.ts b/server/src/socket/terminalRegistry.ts new file mode 100644 index 00000000..53942634 --- /dev/null +++ b/server/src/socket/terminalRegistry.ts @@ -0,0 +1,140 @@ +export type TerminalRegistryEntry = { + terminalId: string + sessionId: string + socketId: string + cliSocketId: string + idleTimer: ReturnType | null +} + +type TerminalRegistryOptions = { + idleTimeoutMs: number + onIdle?: (entry: TerminalRegistryEntry) => void +} + +export class TerminalRegistry { + private readonly terminals = new Map() + private readonly terminalsBySocket = new Map>() + private readonly terminalsBySession = new Map>() + private readonly terminalsByCliSocket = new Map>() + 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>, 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>, key: string, terminalId: string): void { + const set = index.get(key) + if (!set) { + return + } + set.delete(terminalId) + if (set.size === 0) { + index.delete(key) + } + } +} diff --git a/web/package.json b/web/package.json index 7b28daf2..a43e5075 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 4b2bc5bb..32f10c24 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -38,6 +38,26 @@ function SwitchToRemoteIcon() { ) } +function TerminalIcon() { + return ( + + + + + + ) +} + 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: { ) : null} + {props.showTerminalButton ? ( + + ) : null} + {props.showAbortButton ? ( +
+
Terminal
+
{subtitle}
+
+ + + + + {session.active ? null : ( +
+
+ Session is inactive. Terminal is unavailable. +
+
+ )} + + {errorMessage ? ( +
+
+ {errorMessage} +
+
+ ) : null} + + {exitInfo ? ( +
+
+ Terminal exited{exitInfo.code !== null ? ` with code ${exitInfo.code}` : ''}{exitInfo.signal ? ` (${exitInfo.signal})` : ''}. +
+
+ ) : null} + +
+
+ +
+
+ + ) +}