- Add multiple hook-driven animations for FAB (processing, reading, writing, subagent, sessionStart, notification) - Create claude-status.ts route to handle status updates from Claude Code hooks - Broadcast status via WebSocket to all connected clients - Processing (UserPromptSubmit→Stop): orange pulsing dots - Reading (Read/Glob/Grep): cyan eye icon with scan animation - Writing (Edit/Write): green pencil icon with pulse - Subagent: purple orbital ring animation - SessionStart: green wake-up ripple effect (3s) - Notification: yellow bounce animation (2s) - Tool flash: quick white flash on any tool use
242 lines
7.6 KiB
TypeScript
242 lines
7.6 KiB
TypeScript
import { spawn, type IPty } from '@skitee3000/bun-pty'
|
|
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config'
|
|
|
|
interface TerminalSession {
|
|
id: string
|
|
pty: IPty
|
|
outputBuffer: string[]
|
|
maxBufferSize: number
|
|
clients: Set<any>
|
|
createdAt: Date
|
|
}
|
|
|
|
// Store active terminal sessions by ID (persistent across reconnections)
|
|
const sessions = new Map<string, TerminalSession>()
|
|
|
|
// Map WebSocket to sessionId
|
|
const wsToSession = new Map<any, string>()
|
|
|
|
function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession {
|
|
let session = sessions.get(sessionId)
|
|
|
|
if (!session) {
|
|
console.log(`[Terminal] Creating new session: ${sessionId}`)
|
|
const pty = spawn(SHELL, SHELL_ARGS, {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: WORKING_DIR
|
|
})
|
|
|
|
session = {
|
|
id: sessionId,
|
|
pty,
|
|
outputBuffer: [],
|
|
maxBufferSize: MAX_BUFFER_LINES,
|
|
clients: new Set(),
|
|
createdAt: new Date()
|
|
}
|
|
|
|
// Capture output to buffer and send to clients
|
|
pty.onData((data: string) => {
|
|
session!.outputBuffer.push(data)
|
|
if (session!.outputBuffer.length > session!.maxBufferSize) {
|
|
session!.outputBuffer.shift()
|
|
}
|
|
|
|
for (const ws of session!.clients) {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'output', data }))
|
|
} catch {
|
|
// Client disconnected
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle PTY exit
|
|
pty.onExit(({ exitCode }) => {
|
|
console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`)
|
|
for (const ws of session!.clients) {
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'exit',
|
|
data: `\r\n\x1b[33mSession ended (code ${exitCode})\x1b[0m\r\n`
|
|
}))
|
|
} catch { /* ignore */ }
|
|
}
|
|
sessions.delete(sessionId)
|
|
})
|
|
|
|
sessions.set(sessionId, session)
|
|
console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`)
|
|
}
|
|
|
|
return session
|
|
}
|
|
|
|
export function startTerminalServer() {
|
|
const server = Bun.serve({
|
|
port: PORT_TERMINAL,
|
|
async fetch(req, server) {
|
|
const url = new URL(req.url)
|
|
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type'
|
|
}
|
|
|
|
// CORS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, { headers: corsHeaders })
|
|
}
|
|
|
|
// Health check with session info
|
|
if (url.pathname === '/health') {
|
|
const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({
|
|
id,
|
|
clients: s.clients.size,
|
|
pid: s.pty.pid,
|
|
bufferSize: s.outputBuffer.length,
|
|
createdAt: s.createdAt.toISOString()
|
|
}))
|
|
return Response.json({
|
|
status: 'ok',
|
|
sessions: sessionsInfo,
|
|
cwd: WORKING_DIR
|
|
}, { headers: corsHeaders })
|
|
}
|
|
|
|
// List active sessions
|
|
if (url.pathname === '/sessions') {
|
|
const list = Array.from(sessions.keys())
|
|
return Response.json({ sessions: list })
|
|
}
|
|
|
|
// Claude status broadcast endpoint
|
|
if (url.pathname === '/claude-status' && req.method === 'POST') {
|
|
try {
|
|
const body = await req.json() as { status: ClaudeStatus, tool?: string }
|
|
broadcastClaudeStatus(body.status, body.tool)
|
|
return Response.json({ success: true }, { headers: corsHeaders })
|
|
} catch {
|
|
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
|
}
|
|
}
|
|
|
|
// Check if this is a WebSocket upgrade request
|
|
const upgradeHeader = req.headers.get('upgrade')
|
|
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
|
|
|
if (upgradeHeader?.toLowerCase() === 'websocket') {
|
|
const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID
|
|
const success = server.upgrade(req, { data: { sessionId } })
|
|
console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`)
|
|
if (success) {
|
|
return undefined
|
|
}
|
|
return new Response('WebSocket upgrade failed', { status: 400 })
|
|
}
|
|
|
|
return new Response(
|
|
'Terminal WebSocket Server - Persistent Sessions\n\nEndpoints:\n /health - Server status\n /sessions - List active sessions\n ws://...?session=<id> - Connect to session',
|
|
{ status: 200 }
|
|
)
|
|
},
|
|
websocket: {
|
|
open(ws) {
|
|
const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID
|
|
console.log(`[Terminal] Client connecting to session: ${sessionId}`)
|
|
|
|
try {
|
|
const session = getOrCreateSession(sessionId)
|
|
session.clients.add(ws)
|
|
wsToSession.set(ws, sessionId)
|
|
|
|
// Send connection info
|
|
ws.send(JSON.stringify({
|
|
type: 'connected',
|
|
sessionId: session.id,
|
|
isNew: session.outputBuffer.length === 0
|
|
}))
|
|
|
|
// Replay buffer if there's history
|
|
if (session.outputBuffer.length > 0) {
|
|
console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`)
|
|
ws.send(JSON.stringify({
|
|
type: 'replay',
|
|
data: session.outputBuffer.join('')
|
|
}))
|
|
}
|
|
|
|
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
|
|
} catch (e: any) {
|
|
console.error('[Terminal] Error:', e)
|
|
ws.send(JSON.stringify({ type: 'error', message: e.message }))
|
|
}
|
|
},
|
|
message(ws, message) {
|
|
try {
|
|
const msg = JSON.parse(message as string)
|
|
const sessionId = wsToSession.get(ws)
|
|
if (!sessionId) return
|
|
|
|
const session = sessions.get(sessionId)
|
|
if (!session) return
|
|
|
|
if (msg.type === 'input') {
|
|
session.pty.write(msg.data)
|
|
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
|
session.pty.resize(msg.cols, msg.rows)
|
|
console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`)
|
|
}
|
|
} catch (e: any) {
|
|
console.error('[Terminal] Error:', e)
|
|
}
|
|
},
|
|
close(ws) {
|
|
const sessionId = wsToSession.get(ws)
|
|
if (sessionId) {
|
|
const session = sessions.get(sessionId)
|
|
if (session) {
|
|
session.clients.delete(ws)
|
|
console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`)
|
|
// Don't kill PTY - session persists
|
|
}
|
|
wsToSession.delete(ws)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
console.log(`[Terminal] WebSocket running at ws://localhost:${PORT_TERMINAL}`)
|
|
return server
|
|
}
|
|
|
|
// Claude status types
|
|
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'thinking'
|
|
|
|
// Broadcast Claude status to ALL clients across ALL sessions
|
|
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string) {
|
|
const message = JSON.stringify({
|
|
type: 'claude-status',
|
|
status,
|
|
tool,
|
|
timestamp: Date.now()
|
|
})
|
|
|
|
let clientCount = 0
|
|
for (const [, session] of sessions) {
|
|
for (const ws of session.clients) {
|
|
try {
|
|
ws.send(message)
|
|
clientCount++
|
|
} catch {
|
|
// Client disconnected, ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''} → ${clientCount} clients`)
|
|
}
|