feat: interactive ephemeral terminal per transcript session

Replace one-shot HTTP POST sendPrompt with a persistent ephemeral
terminal per session. Terminal auto-starts on session select, stays
running in background when modal is closed, and gets killed on
session switch or page unload.

- Add sendInput() to useEphemeralTerminal (text + Enter as separate WS messages)
- useTranscriptDebug owns terminal lifecycle (create/dispose on select/switch)
- ResumeTerminalButton receives shared terminal prop, only toggles modal
- UserInput shows "Starting terminal..." when not ready
- Add "New Session" button that starts a fresh agent session
- beforeunload sends sendBeacon to kill terminal on page close
This commit is contained in:
2026-02-19 18:55:23 -06:00
parent eb2bafaea1
commit 016e92ffe5
7 changed files with 233 additions and 108 deletions

View File

@@ -30,9 +30,12 @@ const {
error,
isRealtime,
processing,
ephemeral,
terminalReady,
init,
switchAgent,
selectSession,
createNewSession,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
@@ -335,6 +338,10 @@ function handleSend(message: string) {
sendPrompt(message)
}
function handleCreateSession() {
createNewSession()
}
// ============================================================================
// WATCHERS
// ============================================================================
@@ -485,6 +492,8 @@ onBeforeUnmount(() => {
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:show-selector="showSelector"
:agents="agents"
:selected-agent="selectedAgent"
@@ -494,6 +503,7 @@ onBeforeUnmount(() => {
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
@create-session="handleCreateSession"
/>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">

View File

@@ -8,6 +8,7 @@ import type {
ConversationMessage,
AgentName
} from '@/types/transcript-debug'
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
import UserMessageBubble from './UserMessageBubble.vue'
import AssistantMessageBubble from './AssistantMessageBubble.vue'
import ProgressEvent from './ProgressEvent.vue'
@@ -18,6 +19,8 @@ import ResumeTerminalButton from './ResumeTerminalButton.vue'
const props = defineProps<{
conversation: ParsedConversation
processing?: boolean
terminalReady?: boolean
terminal?: EphemeralTerminal | null
showSelector?: boolean
agents?: { id: AgentName; label: string }[]
selectedAgent?: AgentName | null
@@ -30,6 +33,7 @@ const emit = defineEmits<{
send: [message: string]
switchAgent: [agent: AgentName]
selectSession: [sessionId: string]
createSession: []
}>()
const scrollContainer = ref<HTMLElement | null>(null)
@@ -254,6 +258,13 @@ function formatDuration(start: string, end: string): string {
{{ s.firstUserMessage ? (s.firstUserMessage.length > 50 ? s.firstUserMessage.slice(0, 50) + '...' : s.firstUserMessage) : s.id.slice(0, 8) + '...' }}
</option>
</select>
<button class="new-session-btn" @click="emit('createSession')" title="Start new session">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span>New</span>
</button>
<span v-if="sessionsLoading" class="spinner-sm"></span>
</div>
<div class="selector-row">
@@ -272,6 +283,7 @@ function formatDuration(start: string, end: string): string {
v-if="selectedAgent"
:agent="selectedAgent"
:session-id="conversation.sessionId"
:terminal="terminal ?? null"
/>
</div>
</div>
@@ -354,6 +366,7 @@ function formatDuration(start: string, end: string): string {
<UserInput
:processing="props.processing"
:terminal-ready="props.terminalReady"
@send="emit('send', $event)"
/>
@@ -365,6 +378,7 @@ function formatDuration(start: string, end: string): string {
v-if="selectedAgent"
:agent="selectedAgent"
:session-id="conversation.sessionId"
:terminal="terminal ?? null"
/>
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
@@ -739,6 +753,31 @@ function formatDuration(start: string, end: string): string {
color: #ccc;
}
.new-session-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
border-radius: 0;
color: #4ade80;
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.new-session-btn:hover {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.4);
color: #86efac;
}
.spinner-sm {
width: 12px;
height: 12px;

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import type { AgentName } from '@/types/transcript-debug'
import { useEphemeralTerminal, type EphemeralTerminal } from '@/composables/useEphemeralTerminal'
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
import TerminalNavButtons from '../TerminalNavButtons.vue'
const props = defineProps<{
agent: AgentName
sessionId: string
terminal: EphemeralTerminal | null
}>()
const AGENT_CMD: Record<AgentName, string> = {
@@ -16,7 +17,6 @@ const AGENT_CMD: Record<AgentName, string> = {
}
const isOpen = ref(false)
let ephemeral: EphemeralTerminal | null = null
// Local ref for xterm container - syncs to composable's containerRef
const terminalContainer = ref<HTMLElement | null>(null)
@@ -38,8 +38,8 @@ const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
const windowRef = ref<HTMLElement | null>(null)
const statusDotClass = computed(() => {
if (!ephemeral) return ''
switch (ephemeral.state.value) {
if (!props.terminal) return ''
switch (props.terminal.state.value) {
case 'running':
case 'shell-ready': return 'on'
case 'connecting': return 'wait'
@@ -71,12 +71,11 @@ const terminalStyle = computed((): Record<string, string> => {
async function open() {
if (isOpen.value) {
await closeTerminal()
closeTerminal()
return
}
const cmd = `${AGENT_CMD[props.agent]} --resume "${props.sessionId}"`
ephemeral = useEphemeralTerminal(cmd)
if (!props.terminal) return
isOpen.value = true
@@ -84,29 +83,24 @@ async function open() {
// Sync container ref
if (terminalContainer.value) {
ephemeral.containerRef.value = terminalContainer.value
props.terminal.containerRef.value = terminalContainer.value
}
// Init renderer then start
if (!ephemeral.renderer.isReady.value) {
ephemeral.renderer.init()
// Init renderer if needed
if (!props.terminal.renderer.isReady.value) {
props.terminal.renderer.init()
}
setTimeout(() => {
ephemeral?.renderer.fit()
ephemeral?.renderer.terminal.value?.refresh(0, (ephemeral.renderer.terminal.value?.rows ?? 1) - 1)
props.terminal?.renderer.fit()
props.terminal?.renderer.terminal.value?.refresh(0, (props.terminal.renderer.terminal.value?.rows ?? 1) - 1)
if (window.innerWidth > 1024) {
ephemeral?.renderer.focus()
props.terminal?.renderer.focus()
}
ephemeral?.start()
}, 150)
}
async function closeTerminal() {
if (ephemeral) {
await ephemeral.dispose()
ephemeral = null
}
function closeTerminal() {
isOpen.value = false
hasCustomPosition.value = false
showNavButtons.value = false
@@ -114,44 +108,24 @@ async function closeTerminal() {
// ── Nav button actions ──
function sendInput(text: string) {
if (!ephemeral) return
const chars = (text + '\r').split('')
let i = 0
const typeChar = () => {
if (i < chars.length) {
ephemeral?.renderer.terminal.value // just to keep ref
// Send through the composable's WS indirectly via onData
// We need direct WS access, so use sendRaw-like approach
const data = chars[i]
// The composable's onData callback sends to WS, so we write to terminal input
// Actually we need to send via the ephemeral's internal socket
// Let's just type into the terminal which triggers onData → WS
i++
setTimeout(typeChar, 15)
}
}
typeChar()
}
function navRunClaude() {
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + '\r')
props.terminal?.sendInput(AGENT_CMD[props.agent])
}
function navRunClaudeContinue() {
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + ' --continue\r')
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --continue')
}
function navRunClaudeResume() {
ephemeral?.renderer.terminal.value?.paste(AGENT_CMD[props.agent] + ' --resume\r')
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --resume')
}
function navRefresh() {
ephemeral?.renderer.fit()
props.terminal?.renderer.fit()
}
function navClearBuffer() {
ephemeral?.renderer.reset()
props.terminal?.renderer.reset()
}
function navSendKey(key: string) {
@@ -160,14 +134,14 @@ function navSendKey(key: string) {
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
}
const data = keyMap[key]
if (data) ephemeral?.renderer.terminal.value?.paste(data)
if (data) props.terminal?.renderer.terminal.value?.paste(data)
}
function navScroll(direction: 'up' | 'down' | 'end') {
if (!ephemeral) return
if (direction === 'up') ephemeral.renderer.scrollLines(-10)
else if (direction === 'down') ephemeral.renderer.scrollLines(10)
else ephemeral.renderer.scrollToBottom()
if (!props.terminal) return
if (direction === 'up') props.terminal.renderer.scrollLines(-10)
else if (direction === 'down') props.terminal.renderer.scrollLines(10)
else props.terminal.renderer.scrollToBottom()
}
function toggleNavButtons() {
@@ -246,21 +220,17 @@ function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => ephemeral?.renderer.fit())
nextTick(() => props.terminal?.renderer.fit())
}
// Sync container ref when it mounts
watch(terminalContainer, (el) => {
if (ephemeral && el) {
ephemeral.containerRef.value = el
if (props.terminal && el) {
props.terminal.containerRef.value = el
}
})
onBeforeUnmount(async () => {
if (ephemeral) {
await ephemeral.dispose()
ephemeral = null
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
@@ -302,9 +272,9 @@ onBeforeUnmount(async () => {
<span class="rt-name">{{ AGENT_CMD[agent] }}</span>
<span class="rt-session">{{ sessionId.slice(0, 8) }}</span>
<i class="rt-dot" :class="statusDotClass"></i>
<span v-if="ephemeral?.state.value === 'connecting'" class="rt-status-text">Connecting...</span>
<span v-else-if="ephemeral?.state.value === 'shell-ready'" class="rt-status-text">Starting...</span>
<span v-else-if="ephemeral?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
<span v-if="terminal?.state.value === 'connecting'" class="rt-status-text">Connecting...</span>
<span v-else-if="terminal?.state.value === 'shell-ready'" class="rt-status-text">Starting...</span>
<span v-else-if="terminal?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
</div>
<div class="window-controls">
<button
@@ -326,7 +296,7 @@ onBeforeUnmount(async () => {
<div ref="terminalContainer" class="rt-term"></div>
<!-- Overlay: connecting -->
<div v-if="ephemeral?.state.value === 'connecting'" class="rt-overlay connecting">
<div v-if="terminal?.state.value === 'connecting'" class="rt-overlay connecting">
<div class="rt-overlay-msg">
<div class="rt-spinner"></div>
<span>Connecting...</span>
@@ -342,7 +312,7 @@ onBeforeUnmount(async () => {
<TerminalNavButtons
v-if="showNavButtons"
class="rt-nav-popup"
@request-token="ephemeral?.renderer.terminal.value?.paste('genera token usando tu mcp\r')"
@request-token="terminal?.sendInput('genera token usando tu mcp')"
@run-claude="navRunClaude"
@run-claude-continue="navRunClaudeContinue"
@run-claude-resume="navRunClaudeResume"

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
const props = defineProps<{
processing?: boolean
terminalReady?: boolean
}>()
const emit = defineEmits<{
@@ -11,11 +12,12 @@ const emit = defineEmits<{
const input = ref('')
const isDisabled = computed(() => !input.value.trim() || props.processing)
const notReady = computed(() => props.terminalReady === false)
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
function handleSend() {
const msg = input.value.trim()
if (!msg || props.processing) return
if (!msg || props.processing || notReady.value) return
emit('send', msg)
input.value = ''
}
@@ -30,19 +32,25 @@ function handleKeydown(e: KeyboardEvent) {
<template>
<div class="user-input">
<div v-if="processing" class="processing-bar">
<div v-if="notReady" class="processing-bar starting">
<span class="processing-dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</span>
<span>Starting terminal...</span>
</div>
<div v-else-if="processing" class="processing-bar">
<span class="processing-dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</span>
<span>Agent is processing...</span>
</div>
<div class="input-container" :class="{ disabled: processing }">
<div class="input-container" :class="{ disabled: processing || notReady }">
<textarea
v-model="input"
class="input-field"
:placeholder="processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
rows="1"
:disabled="processing"
:disabled="processing || notReady"
@keydown="handleKeydown"
/>
<button
@@ -62,7 +70,7 @@ function handleKeydown(e: KeyboardEvent) {
<style scoped>
.user-input {
padding: 0.5rem 0.75rem;
padding: 0.5rem 0.75rem 0.15rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
@@ -77,6 +85,10 @@ function handleKeydown(e: KeyboardEvent) {
color: var(--accent);
}
.processing-bar.starting {
color: var(--text-muted, #888);
}
.processing-dots {
display: flex;
gap: 3px;

View File

@@ -1,5 +1,6 @@
import { ref, computed, onUnmounted } from 'vue'
import { endpoints } from '@/config/endpoints'
import { ref, shallowRef, computed, onUnmounted } from 'vue'
import { endpoints, terminalApiUrl } from '@/config/endpoints'
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
import type {
AgentName,
SessionInfo,
@@ -58,6 +59,49 @@ export function useTranscriptDebug() {
const error = ref<string | null>(null)
const isRealtime = ref(false)
// ── Ephemeral terminal ──
const AGENT_CMD: Record<AgentName, string> = {
ejecutor: 'ejecutor',
nucleo000: 'nucleo000',
claude: 'claude'
}
const ephemeral = shallowRef<EphemeralTerminal | null>(null)
const terminalReady = computed(() => ephemeral.value?.state.value === 'running')
async function disposeTerminal() {
if (ephemeral.value) {
await ephemeral.value.dispose()
ephemeral.value = null
}
}
const awaitingNewSession = ref(false)
function startTerminal(sessionId?: string) {
const cmd = sessionId
? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"`
: AGENT_CMD[selectedAgent.value]
const term = useEphemeralTerminal(cmd)
ephemeral.value = term
term.start()
}
async function createNewSession() {
await disposeTerminal()
selectedSessionId.value = null
rawContent.value = ''
conversation.value = null
error.value = null
processing.value = false
optimisticMessage.value = null
awaitingNewSession.value = true
startTerminal() // no sessionId → brand new session
}
// ── WebSocket realtime ──
let socket: WebSocket | null = null
@@ -114,6 +158,15 @@ export function useTranscriptDebug() {
// Refresh session list (new sessions or size changes)
await fetchSessions()
// New session just appeared — lock onto it without restarting terminal
if (awaitingNewSession.value) {
awaitingNewSession.value = false
selectedSessionId.value = changedSessionId
saveState()
await fetchSessionContent(changedSessionId)
return
}
// If the changed session is the one we're viewing, re-fetch it
if (selectedSessionId.value && selectedSessionId.value === changedSessionId) {
await reloadCurrentSession()
@@ -172,9 +225,26 @@ export function useTranscriptDebug() {
}
}
// Kill terminal on page close/refresh
function onBeforeUnload() {
if (ephemeral.value) {
const sessionId = ephemeral.value.ephemeralSessionId
navigator.sendBeacon(
terminalApiUrl('/kill-session'),
JSON.stringify({ sessionId })
)
}
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', onBeforeUnload)
}
// Auto-cleanup on unmount
onUnmounted(() => {
disposeTerminal()
disconnectRealtime()
window.removeEventListener('beforeunload', onBeforeUnload)
})
// ── Send prompt (spawns independent claude process) ──
@@ -185,24 +255,14 @@ export function useTranscriptDebug() {
async function sendPrompt(text: string) {
if (!text.trim() || !selectedSessionId.value) return
sending.value = true
error.value = null
try {
const res = await fetch('/api/transcript-debug/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent: selectedAgent.value,
sessionId: selectedSessionId.value,
prompt: text
})
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
if (!ephemeral.value || ephemeral.value.state.value !== 'running') {
error.value = 'Terminal not ready — wait for it to start'
return
}
// POST returned — agent is spawned
error.value = null
ephemeral.value.sendInput(text)
processing.value = true
// Optimistic: show user message immediately in chat
@@ -220,11 +280,6 @@ export function useTranscriptDebug() {
if (conversation.value) {
conversation.value.messages.push(optimisticMessage.value)
}
} catch (e: any) {
error.value = `Failed to send: ${e.message}`
} finally {
sending.value = false
}
}
// ── API ──
@@ -271,6 +326,7 @@ export function useTranscriptDebug() {
selectedSessionId.value = targetSession
saveState()
await fetchSessionContent(targetSession)
startTerminal(targetSession)
} else {
selectedSessionId.value = null
saveState()
@@ -283,6 +339,8 @@ export function useTranscriptDebug() {
async function switchAgent(agent: AgentName) {
if (agent === selectedAgent.value) return
await disposeTerminal()
selectedAgent.value = agent
error.value = null
loading.value = true
@@ -299,6 +357,7 @@ export function useTranscriptDebug() {
if (sessions.value.length > 0) {
selectedSessionId.value = sessions.value[0].id
await fetchSessionContent(sessions.value[0].id)
startTerminal(sessions.value[0].id)
} else {
selectedSessionId.value = null
}
@@ -311,6 +370,8 @@ export function useTranscriptDebug() {
async function selectSession(sessionId: string) {
if (sessionId === selectedSessionId.value) return
await disposeTerminal()
error.value = null
loading.value = true
transitioning.value = true
@@ -324,6 +385,8 @@ export function useTranscriptDebug() {
transitioning.value = false
loading.value = false
startTerminal(sessionId)
}
// ── JSONL Parser ──
@@ -609,10 +672,14 @@ export function useTranscriptDebug() {
isRealtime,
sending,
processing,
ephemeral,
terminalReady,
awaitingNewSession,
init,
fetchSessions,
switchAgent,
selectSession,
createNewSession,
connectRealtime,
disconnectRealtime,
sendPrompt

View File

@@ -24,6 +24,9 @@ export interface EphemeralTerminal {
/** Connect WS, wait for shell, then auto-run the resume command */
start: () => void
/** Send text + \r directly to the terminal's WebSocket */
sendInput: (text: string) => void
/** Ctrl+C, exit, close WS, kill session on server */
stop: () => Promise<void>
@@ -136,6 +139,19 @@ export function useEphemeralTerminal(
}
}
function sendInput(text: string) {
if (!socket || socket.readyState !== WebSocket.OPEN) return
// Send text first, then Enter as a separate message —
// sending them together makes Claude Code treat \r as a
// literal line-break inside the prompt text.
socket.send(JSON.stringify({ type: 'input', data: text }))
setTimeout(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: '\r' }))
}
}, 80)
}
async function stop() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
state.value = 'off'
@@ -179,6 +195,7 @@ export function useEphemeralTerminal(
renderer,
ephemeralSessionId,
start,
sendInput,
stop,
dispose
}

View File

@@ -17,9 +17,12 @@ const {
isRealtime,
sending,
processing,
ephemeral,
terminalReady,
init,
switchAgent,
selectSession,
createNewSession,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
@@ -34,6 +37,10 @@ function handleSend(message: string) {
sendPrompt(message)
}
function handleCreateSession() {
createNewSession()
}
onMounted(() => {
init()
})
@@ -109,8 +116,11 @@ onUnmounted(() => {
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:selected-agent="selectedAgent"
@send="handleSend"
@create-session="handleCreateSession"
/>
</div>
</div>