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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,45 +255,30 @@ export function useTranscriptDebug() {
|
||||
|
||||
async function sendPrompt(text: string) {
|
||||
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
|
||||
ephemeral.value.sendInput(text)
|
||||
processing.value = true
|
||||
|
||||
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}`)
|
||||
}
|
||||
// POST returned — agent is spawned
|
||||
processing.value = true
|
||||
// Optimistic: show user message immediately in chat
|
||||
// Stays until the real user entry appears in the JSONL
|
||||
optimisticMessage.value = {
|
||||
kind: 'user',
|
||||
uuid: `optimistic-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
content: text,
|
||||
isMeta: false,
|
||||
isToolResult: false,
|
||||
toolResults: []
|
||||
} as ParsedUserMessage
|
||||
|
||||
// Optimistic: show user message immediately in chat
|
||||
// Stays until the real user entry appears in the JSONL
|
||||
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
|
||||
if (conversation.value) {
|
||||
conversation.value.messages.push(optimisticMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user