fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input

- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug
- Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable
- Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat
- Guard xterm dispose against unloaded addons to prevent errors on agent switch
- Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
This commit is contained in:
2026-02-16 00:41:38 -06:00
parent 59cc8ee87e
commit 55265d5145
18 changed files with 2308 additions and 96 deletions

View File

@@ -10,6 +10,23 @@ interface TerminalSession {
createdAt: Date
}
// Agent terminal state tracking
interface AgentTerminalState {
agentId: string
sessionId: string
command: string
startedAt: Date | null
isAgentRunning: boolean
}
export const agentSessions = new Map<string, AgentTerminalState>()
const AGENT_COMMANDS: Record<string, string> = {
'main': 'claude',
'ejecutor': 'ejecutor',
'nucleo000': 'nucleo000'
}
// Store active terminal sessions by ID (persistent across reconnections)
const sessions = new Map<string, TerminalSession>()
@@ -65,6 +82,16 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
} catch { /* ignore */ }
}
sessions.delete(sessionId)
// Mark agent as not running if this is an agent session
if (sessionId.startsWith('agent-')) {
const agentId = sessionId.replace('agent-', '')
const state = agentSessions.get(agentId)
if (state) {
state.isAgentRunning = false
console.log(`[Terminal] Agent ${agentId} marked as stopped (exit code ${exitCode})`)
}
}
})
sessions.set(sessionId, session)
@@ -74,6 +101,60 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
return session
}
// Kill an existing session's PTY process
export function killSession(sessionId: string): boolean {
const session = sessions.get(sessionId)
if (!session) return false
console.log(`[Terminal] Killing session: ${sessionId} (PID: ${session.pty.pid})`)
// Notify clients before killing
for (const ws of session.clients) {
try {
ws.send(JSON.stringify({ type: 'session-restart', sessionId }))
} catch { /* ignore */ }
}
try {
session.pty.kill()
} catch (e) {
console.error(`[Terminal] Error killing PTY for ${sessionId}:`, e)
}
sessions.delete(sessionId)
return true
}
// Start an agent command in its dedicated session
export async function startAgentInSession(agentId: string, force = false): Promise<AgentTerminalState> {
const sessionId = `agent-${agentId}`
const command = AGENT_COMMANDS[agentId] || agentId
// If force restart, kill existing session first
if (force && sessions.has(sessionId)) {
killSession(sessionId)
await new Promise(r => setTimeout(r, 300))
}
const session = getOrCreateSession(sessionId)
// Write the agent command to the PTY
session.pty.write(command + '\r')
const state: AgentTerminalState = {
agentId,
sessionId,
command,
startedAt: new Date(),
isAgentRunning: true
}
agentSessions.set(agentId, state)
console.log(`[Terminal] Agent ${agentId} started in session ${sessionId} with command: ${command}`)
return state
}
export function startTerminalServer() {
const server = Bun.serve({
port: PORT_TERMINAL,
@@ -146,6 +227,66 @@ export function startTerminalServer() {
}
}
// Agent sessions info
if (url.pathname === '/agent-sessions' && req.method === 'GET') {
const result: Record<string, any> = {}
for (const [id, state] of agentSessions) {
const session = sessions.get(state.sessionId)
result[id] = {
...state,
pid: session?.pty.pid ?? null,
bufferSize: session?.outputBuffer.length ?? 0,
clientCount: session?.clients.size ?? 0,
sessionExists: !!session
}
}
return Response.json(result, { headers: corsHeaders })
}
// Start agent in session
if (url.pathname === '/start-agent' && req.method === 'POST') {
try {
const body = await req.json() as { agentId: string; force?: boolean }
if (!body.agentId) {
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
}
const state = await startAgentInSession(body.agentId, body.force)
return Response.json({ success: true, state }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// Stop agent session
if (url.pathname === '/stop-agent' && req.method === 'POST') {
try {
const body = await req.json() as { agentId: string }
if (!body.agentId) {
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
}
const sessionId = `agent-${body.agentId}`
const killed = killSession(sessionId)
if (killed) {
const state = agentSessions.get(body.agentId)
if (state) state.isAgentRunning = false
}
return Response.json({ success: true, killed }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// Transcript update broadcast endpoint
if (url.pathname === '/transcript-update' && req.method === 'POST') {
try {
const body = await req.json()
broadcastTranscriptUpdate(body as Record<string, unknown>)
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}`)
@@ -269,11 +410,22 @@ type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' |
// Broadcast Claude status to ALL clients across ALL sessions
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
const agentName = agent || 'main'
// Track agent running state from sessionStart
if (status === 'sessionStart') {
const state = agentSessions.get(agentName)
if (state) {
state.isAgentRunning = true
console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`)
}
}
const message = JSON.stringify({
type: 'claude-status',
status,
tool,
agent: agent || 'main',
agent: agentName,
timestamp: Date.now()
})
@@ -337,3 +489,24 @@ export function broadcastPermissionRequest(data: Record<string, unknown>) {
console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`)
}
// Broadcast transcript updates to ALL clients
export function broadcastTranscriptUpdate(data: Record<string, unknown>) {
const message = JSON.stringify({
type: 'transcript-update',
...data,
timestamp: Date.now()
})
let clientCount = 0
for (const [, session] of sessions) {
for (const ws of session.clients) {
try {
ws.send(message)
clientCount++
} catch { /* skip */ }
}
}
console.log(`[Terminal] Transcript update: ${data.hookEvent || 'fetch'} (${(data.messages as any[])?.length || 0} msgs) → ${clientCount} clients`)
}