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, error,
isRealtime, isRealtime,
processing, processing,
ephemeral,
terminalReady,
init, init,
switchAgent, switchAgent,
selectSession, selectSession,
createNewSession,
disconnectRealtime, disconnectRealtime,
sendPrompt sendPrompt
} = useTranscriptDebug() } = useTranscriptDebug()
@@ -335,6 +338,10 @@ function handleSend(message: string) {
sendPrompt(message) sendPrompt(message)
} }
function handleCreateSession() {
createNewSession()
}
// ============================================================================ // ============================================================================
// WATCHERS // WATCHERS
// ============================================================================ // ============================================================================
@@ -485,6 +492,8 @@ onBeforeUnmount(() => {
v-if="conversation" v-if="conversation"
:conversation="conversation" :conversation="conversation"
:processing="processing" :processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:show-selector="showSelector" :show-selector="showSelector"
:agents="agents" :agents="agents"
:selected-agent="selectedAgent" :selected-agent="selectedAgent"
@@ -494,6 +503,7 @@ onBeforeUnmount(() => {
@send="handleSend" @send="handleSend"
@switch-agent="handleAgentSwitch" @switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect" @select-session="handleSessionSelect"
@create-session="handleCreateSession"
/> />
<div v-else class="empty-state"> <div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <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, ConversationMessage,
AgentName AgentName
} from '@/types/transcript-debug' } from '@/types/transcript-debug'
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
import UserMessageBubble from './UserMessageBubble.vue' import UserMessageBubble from './UserMessageBubble.vue'
import AssistantMessageBubble from './AssistantMessageBubble.vue' import AssistantMessageBubble from './AssistantMessageBubble.vue'
import ProgressEvent from './ProgressEvent.vue' import ProgressEvent from './ProgressEvent.vue'
@@ -18,6 +19,8 @@ import ResumeTerminalButton from './ResumeTerminalButton.vue'
const props = defineProps<{ const props = defineProps<{
conversation: ParsedConversation conversation: ParsedConversation
processing?: boolean processing?: boolean
terminalReady?: boolean
terminal?: EphemeralTerminal | null
showSelector?: boolean showSelector?: boolean
agents?: { id: AgentName; label: string }[] agents?: { id: AgentName; label: string }[]
selectedAgent?: AgentName | null selectedAgent?: AgentName | null
@@ -30,6 +33,7 @@ const emit = defineEmits<{
send: [message: string] send: [message: string]
switchAgent: [agent: AgentName] switchAgent: [agent: AgentName]
selectSession: [sessionId: string] selectSession: [sessionId: string]
createSession: []
}>() }>()
const scrollContainer = ref<HTMLElement | null>(null) 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) + '...' }} {{ s.firstUserMessage ? (s.firstUserMessage.length > 50 ? s.firstUserMessage.slice(0, 50) + '...' : s.firstUserMessage) : s.id.slice(0, 8) + '...' }}
</option> </option>
</select> </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> <span v-if="sessionsLoading" class="spinner-sm"></span>
</div> </div>
<div class="selector-row"> <div class="selector-row">
@@ -272,6 +283,7 @@ function formatDuration(start: string, end: string): string {
v-if="selectedAgent" v-if="selectedAgent"
:agent="selectedAgent" :agent="selectedAgent"
:session-id="conversation.sessionId" :session-id="conversation.sessionId"
:terminal="terminal ?? null"
/> />
</div> </div>
</div> </div>
@@ -354,6 +366,7 @@ function formatDuration(start: string, end: string): string {
<UserInput <UserInput
:processing="props.processing" :processing="props.processing"
:terminal-ready="props.terminalReady"
@send="emit('send', $event)" @send="emit('send', $event)"
/> />
@@ -365,6 +378,7 @@ function formatDuration(start: string, end: string): string {
v-if="selectedAgent" v-if="selectedAgent"
:agent="selectedAgent" :agent="selectedAgent"
:session-id="conversation.sessionId" :session-id="conversation.sessionId"
:terminal="terminal ?? null"
/> />
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration"> <span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }} {{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
@@ -739,6 +753,31 @@ function formatDuration(start: string, end: string): string {
color: #ccc; 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 { .spinner-sm {
width: 12px; width: 12px;
height: 12px; height: 12px;

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { ref, computed, onUnmounted } from 'vue' import { ref, shallowRef, computed, onUnmounted } from 'vue'
import { endpoints } from '@/config/endpoints' import { endpoints, terminalApiUrl } from '@/config/endpoints'
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
import type { import type {
AgentName, AgentName,
SessionInfo, SessionInfo,
@@ -58,6 +59,49 @@ export function useTranscriptDebug() {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isRealtime = ref(false) 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 ── // ── WebSocket realtime ──
let socket: WebSocket | null = null let socket: WebSocket | null = null
@@ -114,6 +158,15 @@ export function useTranscriptDebug() {
// Refresh session list (new sessions or size changes) // Refresh session list (new sessions or size changes)
await fetchSessions() 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 the changed session is the one we're viewing, re-fetch it
if (selectedSessionId.value && selectedSessionId.value === changedSessionId) { if (selectedSessionId.value && selectedSessionId.value === changedSessionId) {
await reloadCurrentSession() 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 // Auto-cleanup on unmount
onUnmounted(() => { onUnmounted(() => {
disposeTerminal()
disconnectRealtime() disconnectRealtime()
window.removeEventListener('beforeunload', onBeforeUnload)
}) })
// ── Send prompt (spawns independent claude process) ── // ── Send prompt (spawns independent claude process) ──
@@ -185,45 +255,30 @@ export function useTranscriptDebug() {
async function sendPrompt(text: string) { async function sendPrompt(text: string) {
if (!text.trim() || !selectedSessionId.value) return if (!text.trim() || !selectedSessionId.value) return
sending.value = true
if (!ephemeral.value || ephemeral.value.state.value !== 'running') {
error.value = 'Terminal not ready — wait for it to start'
return
}
error.value = null error.value = null
ephemeral.value.sendInput(text)
processing.value = true
try { // Optimistic: show user message immediately in chat
const res = await fetch('/api/transcript-debug/send', { // Stays until the real user entry appears in the JSONL
method: 'POST', optimisticMessage.value = {
headers: { 'Content-Type': 'application/json' }, kind: 'user',
body: JSON.stringify({ uuid: `optimistic-${Date.now()}`,
agent: selectedAgent.value, timestamp: new Date().toISOString(),
sessionId: selectedSessionId.value, content: text,
prompt: text isMeta: false,
}) isToolResult: false,
}) toolResults: []
if (!res.ok) { } as ParsedUserMessage
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
}
// POST returned — agent is spawned
processing.value = true
// Optimistic: show user message immediately in chat if (conversation.value) {
// Stays until the real user entry appears in the JSONL conversation.value.messages.push(optimisticMessage.value)
optimisticMessage.value = {
kind: 'user',
uuid: `optimistic-${Date.now()}`,
timestamp: new Date().toISOString(),
content: text,
isMeta: false,
isToolResult: false,
toolResults: []
} as ParsedUserMessage
if (conversation.value) {
conversation.value.messages.push(optimisticMessage.value)
}
} catch (e: any) {
error.value = `Failed to send: ${e.message}`
} finally {
sending.value = false
} }
} }
@@ -271,6 +326,7 @@ export function useTranscriptDebug() {
selectedSessionId.value = targetSession selectedSessionId.value = targetSession
saveState() saveState()
await fetchSessionContent(targetSession) await fetchSessionContent(targetSession)
startTerminal(targetSession)
} else { } else {
selectedSessionId.value = null selectedSessionId.value = null
saveState() saveState()
@@ -283,6 +339,8 @@ export function useTranscriptDebug() {
async function switchAgent(agent: AgentName) { async function switchAgent(agent: AgentName) {
if (agent === selectedAgent.value) return if (agent === selectedAgent.value) return
await disposeTerminal()
selectedAgent.value = agent selectedAgent.value = agent
error.value = null error.value = null
loading.value = true loading.value = true
@@ -299,6 +357,7 @@ export function useTranscriptDebug() {
if (sessions.value.length > 0) { if (sessions.value.length > 0) {
selectedSessionId.value = sessions.value[0].id selectedSessionId.value = sessions.value[0].id
await fetchSessionContent(sessions.value[0].id) await fetchSessionContent(sessions.value[0].id)
startTerminal(sessions.value[0].id)
} else { } else {
selectedSessionId.value = null selectedSessionId.value = null
} }
@@ -311,6 +370,8 @@ export function useTranscriptDebug() {
async function selectSession(sessionId: string) { async function selectSession(sessionId: string) {
if (sessionId === selectedSessionId.value) return if (sessionId === selectedSessionId.value) return
await disposeTerminal()
error.value = null error.value = null
loading.value = true loading.value = true
transitioning.value = true transitioning.value = true
@@ -324,6 +385,8 @@ export function useTranscriptDebug() {
transitioning.value = false transitioning.value = false
loading.value = false loading.value = false
startTerminal(sessionId)
} }
// ── JSONL Parser ── // ── JSONL Parser ──
@@ -609,10 +672,14 @@ export function useTranscriptDebug() {
isRealtime, isRealtime,
sending, sending,
processing, processing,
ephemeral,
terminalReady,
awaitingNewSession,
init, init,
fetchSessions, fetchSessions,
switchAgent, switchAgent,
selectSession, selectSession,
createNewSession,
connectRealtime, connectRealtime,
disconnectRealtime, disconnectRealtime,
sendPrompt sendPrompt

View File

@@ -24,6 +24,9 @@ export interface EphemeralTerminal {
/** Connect WS, wait for shell, then auto-run the resume command */ /** Connect WS, wait for shell, then auto-run the resume command */
start: () => void start: () => void
/** Send text + \r directly to the terminal's WebSocket */
sendInput: (text: string) => void
/** Ctrl+C, exit, close WS, kill session on server */ /** Ctrl+C, exit, close WS, kill session on server */
stop: () => Promise<void> 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() { async function stop() {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
state.value = 'off' state.value = 'off'
@@ -179,6 +195,7 @@ export function useEphemeralTerminal(
renderer, renderer,
ephemeralSessionId, ephemeralSessionId,
start, start,
sendInput,
stop, stop,
dispose dispose
} }

View File

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