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;