diff --git a/frontend/src/components/transcript-debug/ChatContainer.vue b/frontend/src/components/transcript-debug/ChatContainer.vue index 8fe6a30..c14e042 100644 --- a/frontend/src/components/transcript-debug/ChatContainer.vue +++ b/frontend/src/components/transcript-debug/ChatContainer.vue @@ -5,21 +5,31 @@ import type { ParsedUserMessage, ParsedAssistantMessage, ParsedSystemMessage, - ConversationMessage + ConversationMessage, + AgentName } from '@/types/transcript-debug' import UserMessageBubble from './UserMessageBubble.vue' import AssistantMessageBubble from './AssistantMessageBubble.vue' import ProgressEvent from './ProgressEvent.vue' import SystemMessage from './SystemMessage.vue' import UserInput from './UserInput.vue' +import ResumeTerminalButton from './ResumeTerminalButton.vue' const props = defineProps<{ conversation: ParsedConversation processing?: boolean + showSelector?: boolean + agents?: { id: AgentName; label: string }[] + selectedAgent?: AgentName | null + sessions?: { id: string; firstUserMessage?: string }[] + selectedSessionId?: string | null + sessionsLoading?: boolean }>() const emit = defineEmits<{ send: [message: string] + switchAgent: [agent: AgentName] + selectSession: [sessionId: string] }>() const scrollContainer = ref(null) @@ -42,6 +52,8 @@ function toggleSelectMode() { if (!selectMode.value) selectedUuids.value = new Set() } +defineExpose({ selectMode, toggleSelectMode }) + function toggleSelect(uuid: string) { if (!selectMode.value) return const s = new Set(selectedUuids.value) @@ -213,42 +225,54 @@ function formatDuration(start: string, end: string): string { @@ -348,18 +386,15 @@ function formatDuration(start: string, end: string): string { } .chat-header { - padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + gap: 6px; + padding: 0.4rem 0.75rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); flex-shrink: 0; } -.chat-title-row { - display: flex; - align-items: center; - gap: 0.4rem; -} - .header-spacer { flex: 1; } @@ -395,27 +430,15 @@ function formatDuration(start: string, end: string): string { white-space: nowrap; } -.session-id { - font-size: 11px; - font-weight: 600; - color: var(--text-secondary); - font-family: 'SF Mono', 'Fira Code', monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 320px; - letter-spacing: 0.3px; -} - .copy-id-btn { display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 16px; + height: 16px; border: none; background: transparent; - border-radius: 4px; + border-radius: 3px; cursor: pointer; color: var(--text-muted); flex-shrink: 0; @@ -431,19 +454,31 @@ function formatDuration(start: string, end: string): string { color: #22c55e; } -.chat-meta { +/* ── Status bar (below input) ── */ +.status-bar { display: flex; align-items: center; - gap: 0.5rem; - margin-top: 0.25rem; - flex-wrap: wrap; + gap: 0.4rem; + padding: 0 0.75rem 0.3rem; + background: var(--bg-secondary); + flex-shrink: 0; +} + +.status-id { + font-size: 9px; + font-weight: 600; + color: var(--text-muted); + font-family: 'Courier New', monospace; + letter-spacing: 0.3px; + white-space: nowrap; } .meta-badge { - font-size: 10px; - padding: 0.1rem 0.4rem; - border-radius: 4px; - font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 9px; + padding: 0.05rem 0.3rem; + border-radius: 3px; + font-family: 'Courier New', monospace; + white-space: nowrap; } .meta-badge.model { @@ -456,26 +491,17 @@ function formatDuration(start: string, end: string): string { color: var(--text-muted); } -.meta-cwd { - font-size: 10px; - color: var(--text-muted); - font-family: 'SF Mono', 'Fira Code', monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 250px; -} - .meta-duration { - font-size: 10px; + font-size: 9px; color: var(--text-muted); - font-family: 'SF Mono', 'Fira Code', monospace; + font-family: 'Courier New', monospace; + margin-left: auto; } .meta-count { - font-size: 10px; + font-size: 9px; color: var(--text-muted); - margin-left: auto; + font-family: 'Courier New', monospace; } .messages-scroll { @@ -639,4 +665,92 @@ function formatDuration(start: string, end: string): string { from { opacity: 0.7; } to { opacity: 1; } } + +.selector-row { + display: flex; + align-items: center; + gap: 8px; +} + +.selector-label { + font-size: 9px; + font-weight: 700; + font-family: 'Courier New', monospace; + color: var(--text-muted, rgba(255,255,255,0.4)); + min-width: 48px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.agent-selector { + display: flex; + background: var(--bg-primary, rgba(255,255,255,0.04)); + border: 1px solid var(--border-color, rgba(255,255,255,0.08)); + border-radius: 0; + overflow: hidden; +} + +.agent-btn { + padding: 3px 8px; + background: transparent; + border: none; + color: var(--text-muted, rgba(255,255,255,0.4)); + font-size: 10px; + font-weight: 700; + font-family: 'Courier New', monospace; + cursor: pointer; + transition: all 0.15s; +} + +.agent-btn:not(:last-child) { + border-right: 1px solid var(--border-color, rgba(255,255,255,0.06)); +} + +.agent-btn:hover { + background: var(--bg-hover, rgba(255,255,255,0.06)); + color: var(--text-secondary, rgba(255,255,255,0.7)); +} + +.agent-btn.active { + background: rgba(99, 102, 241, 0.35); + color: #c7d2fe; +} + +.session-select { + flex: 1; + min-width: 0; + padding: 3px 6px; + background: var(--bg-primary, rgba(255,255,255,0.04)); + border: 1px solid var(--border-color, rgba(255,255,255,0.08)); + border-radius: 0; + color: var(--text-primary, rgba(255,255,255,0.8)); + font-size: 10px; + font-family: 'Courier New', monospace; + cursor: pointer; +} + +.session-select:focus { + outline: none; + border-color: rgba(99, 102, 241, 0.4); +} + +.session-select option { + background: #0a0a10; + color: #ccc; +} + +.spinner-sm { + width: 12px; + height: 12px; + border: 2px solid var(--border-color, rgba(255,255,255,0.1)); + border-top-color: #6366f1; + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + diff --git a/frontend/src/components/transcript-debug/ResumeTerminalButton.vue b/frontend/src/components/transcript-debug/ResumeTerminalButton.vue new file mode 100644 index 0000000..c57af4d --- /dev/null +++ b/frontend/src/components/transcript-debug/ResumeTerminalButton.vue @@ -0,0 +1,572 @@ + + + + + diff --git a/frontend/src/components/transcript-debug/index.ts b/frontend/src/components/transcript-debug/index.ts index 39a3bb2..82dddb9 100644 --- a/frontend/src/components/transcript-debug/index.ts +++ b/frontend/src/components/transcript-debug/index.ts @@ -13,4 +13,5 @@ export { default as PermissionApproval } from './PermissionApproval.vue' export { default as PlanApproval } from './PlanApproval.vue' export { default as CodeBlock } from './CodeBlock.vue' export { default as AgentBadge } from './AgentBadge.vue' +export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue' export { AquaticBackground } from './aquaticBackground' diff --git a/frontend/src/composables/useEphemeralTerminal.ts b/frontend/src/composables/useEphemeralTerminal.ts new file mode 100644 index 0000000..7559b49 --- /dev/null +++ b/frontend/src/composables/useEphemeralTerminal.ts @@ -0,0 +1,185 @@ +/** + * useEphemeralTerminal + * + * Lightweight composable for ephemeral terminal sessions. + * Creates a temporary PTY via WebSocket, runs a command, and + * cleans up completely when disposed (no persistent session). + * + * Used by ResumeTerminalButton to open ` --resume `. + */ + +import { ref, computed, type Ref } from 'vue' +import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer' +import { endpoints, terminalApiUrl } from '../config/endpoints' + +export type EphemeralState = 'off' | 'connecting' | 'shell-ready' | 'running' | 'exited' + +export interface EphemeralTerminal { + state: Ref + connected: Ref + containerRef: Ref + renderer: TerminalRenderer + ephemeralSessionId: string + + /** Connect WS, wait for shell, then auto-run the resume command */ + start: () => void + + /** Ctrl+C, exit, close WS, kill session on server */ + stop: () => Promise + + /** Full cleanup (stop + dispose renderer) */ + dispose: () => Promise +} + +export function useEphemeralTerminal( + command: string +): EphemeralTerminal { + const containerRef = ref(null) + const connected = ref(false) + const state = ref('off') + + const ephemeralSessionId = `resume-${Date.now()}` + + let socket: WebSocket | null = null + + const renderer = useTerminalRenderer({ + container: containerRef, + onData: (data) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data })) + } + }, + onResize: (cols, rows) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'resize', cols, rows })) + } + }, + onKeyEvent: (e) => { + // Ctrl+V: Paste + if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') { + e.preventDefault() + navigator.clipboard.readText().then((text) => { + if (text && socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data: text })) + } + }).catch(console.error) + return false + } + // Ctrl+C: Copy selection + if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') { + const sel = renderer.getSelection() + if (sel) { + navigator.clipboard.writeText(sel).catch(console.error) + return false + } + } + return true + } + }) + + function start() { + if (state.value !== 'off') return + state.value = 'connecting' + + const wsBase = endpoints.terminal + const sep = wsBase.includes('?') ? '&' : '?' + const wsUrl = `${wsBase}${sep}session=${ephemeralSessionId}` + + socket = new WebSocket(wsUrl) + + socket.onopen = () => { + connected.value = true + const term = renderer.terminal.value + if (term) { + socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + } + + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'connected': + state.value = 'shell-ready' + // Wait for shell prompt to render, then send the command + setTimeout(() => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data: command + '\r' })) + } + state.value = 'running' + }, 500) + break + case 'replay': + renderer.handleReplay(msg.data || '') + break + case 'output': + renderer.write(msg.data) + break + case 'exit': + renderer.write(msg.data) + state.value = 'exited' + break + case 'error': + renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`) + break + } + } catch { /* ignore parse errors */ } + } + + socket.onclose = () => { + connected.value = false + if (state.value !== 'off') state.value = 'exited' + } + + socket.onerror = () => { + state.value = 'off' + } + } + + async function stop() { + if (!socket || socket.readyState !== WebSocket.OPEN) { + state.value = 'off' + return + } + + // Send Ctrl+C to interrupt running process + socket.send(JSON.stringify({ type: 'input', data: '\x03' })) + await new Promise(r => setTimeout(r, 300)) + + // Send exit to close the shell + socket.send(JSON.stringify({ type: 'input', data: 'exit\r' })) + await new Promise(r => setTimeout(r, 300)) + + // Close WebSocket + socket.onclose = null + socket.close() + socket = null + connected.value = false + state.value = 'off' + + // Force-kill via HTTP as safety net + try { + await fetch(terminalApiUrl('/kill-session'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: ephemeralSessionId }) + }) + } catch { /* best effort */ } + } + + async function dispose() { + await stop() + renderer.dispose() + } + + return { + state, + connected, + containerRef, + renderer, + ephemeralSessionId, + start, + stop, + dispose + } +} diff --git a/frontend/src/pages/TranscriptDebugPage.vue b/frontend/src/pages/TranscriptDebugPage.vue index f31518e..233f997 100644 --- a/frontend/src/pages/TranscriptDebugPage.vue +++ b/frontend/src/pages/TranscriptDebugPage.vue @@ -109,6 +109,7 @@ onUnmounted(() => { v-if="conversation" :conversation="conversation" :processing="processing" + :selected-agent="selectedAgent" @send="handleSend" /> diff --git a/server/services/terminal.ts b/server/services/terminal.ts index e69b8b0..4f3e553 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -276,6 +276,20 @@ export function startTerminalServer() { } } + // Kill a specific session by ID (used for ephemeral sessions) + if (url.pathname === '/kill-session' && req.method === 'POST') { + try { + const body = await req.json() as { sessionId: string } + if (!body.sessionId) { + return Response.json({ error: 'sessionId required' }, { status: 400, headers: corsHeaders }) + } + const killed = killSession(body.sessionId) + 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 {