fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input

- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug
- Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable
- Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat
- Guard xterm dispose against unloaded addons to prevent errors on agent switch
- Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
This commit is contained in:
2026-02-16 00:41:38 -06:00
parent 59cc8ee87e
commit 55265d5145
18 changed files with 2308 additions and 96 deletions

View File

@@ -3,11 +3,13 @@ import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount }
import type { Agent } from '../../types/agent'
import { endpoints } from '../../config/endpoints'
import { useVoiceCapture } from '../../composables/useVoiceCapture'
import { useAgentTerminal } from '../../composables/useAgentTerminal'
import { useCanvasStore } from '../../stores/canvas'
import ChatInput from './ChatInput.vue'
import TranscriptCard from './TranscriptCard.vue'
import InputSettings from './InputSettings.vue'
import ConversationHistory from './ConversationHistory.vue'
import AgentTerminal from './AgentTerminal.vue'
interface ClaudeUsage {
subscription: { type: string; tier: string; label: string; multiplier: number }
@@ -23,6 +25,7 @@ interface ChatMessage {
content: string
status: 'sent' | 'thinking' | 'done'
uuid?: string
toolCalls?: string[]
intervention?: {
type: 'permission' | 'question' | 'plan'
requestId?: string
@@ -60,6 +63,32 @@ const voice = useVoiceCapture({
onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration)
})
// Agent terminal composable
const agentTerminal = useAgentTerminal(props.agent.id)
const showTerminal = ref(false)
const statusDot = computed<'green' | 'yellow' | 'red' | 'gray' | ''>(() => {
switch (agentTerminal.terminalState.value) {
case 'ready': return 'green'
case 'connecting':
case 'agent-starting': return 'yellow'
case 'crashed': return 'red'
case 'off': return 'gray'
default: return ''
}
})
const inputPlaceholder = computed(() => {
const label = props.agent.uiConfig?.label || props.agent.name
switch (agentTerminal.terminalState.value) {
case 'off': return `Agent offline - type to start ${label}`
case 'crashed': return `Agent crashed - type to restart ${label}`
case 'connecting':
case 'agent-starting': return `Starting ${label}...`
default: return `Mensaje a ${label}...`
}
})
const contentEl = ref<HTMLDivElement | null>(null)
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
const isRecording = ref(false)
@@ -96,7 +125,7 @@ const panelStyle = computed(() => {
const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2
const bottomOffset = window.innerHeight - props.anchorRect.top + 12
const panelWidth = 360
const panelWidth = 420
let left = bubbleCenterX - panelWidth / 2
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12))
@@ -112,6 +141,95 @@ const hasContent = computed(() =>
messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value
)
// ── Tool chip registry ──
interface ToolMeta { icon: string; color: string; label?: string }
const TOOL_CATEGORIES: Record<string, ToolMeta> = {
// File ops — cyan
Read: { icon: '◉', color: '#22d3ee' },
Edit: { icon: '✎', color: '#22d3ee' },
Write: { icon: '✦', color: '#22d3ee' },
Glob: { icon: '⊞', color: '#22d3ee' },
Grep: { icon: '⊘', color: '#22d3ee' },
// Terminal — green
Bash: { icon: '▸', color: '#34d399' },
KillShell: { icon: '✕', color: '#34d399', label: 'Kill' },
// Tasks — amber
Task: { icon: '◈', color: '#fbbf24' },
TaskCreate: { icon: '+', color: '#fbbf24', label: 'Task+' },
TaskUpdate: { icon: '↻', color: '#fbbf24', label: 'Task↻' },
TaskList: { icon: '≡', color: '#fbbf24', label: 'Tasks' },
TaskOutput: { icon: '◎', color: '#fbbf24', label: 'TaskOut' },
TodoWrite: { icon: '☑', color: '#fbbf24', label: 'Todo' },
// Web — purple
WebSearch: { icon: '⌕', color: '#a78bfa' },
WebFetch: { icon: '↓', color: '#a78bfa' },
// Interaction — rose
AskUserQuestion: { icon: '?', color: '#fb7185', label: 'Ask' },
EnterPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan↵' },
ExitPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan✓' },
Skill: { icon: '⚡', color: '#fb7185' },
// Resources — slate
ListMcpResourcesTool: { icon: '☰', color: '#94a3b8', label: 'Resources' },
}
function getToolMeta(name: string): ToolMeta {
if (TOOL_CATEGORIES[name]) return TOOL_CATEGORIES[name]
if (name.startsWith('mcp__')) {
const short = name.split('-').pop() || name
return { icon: '◆', color: '#818cf8', label: short }
}
return { icon: '•', color: '#94a3b8', label: name }
}
// ── Display items with tool grouping ──
interface DisplayItem {
type: 'message' | 'tool-group'
msg?: ChatMessage
tools?: { name: string; count: number; meta: ToolMeta }[]
ids?: number[]
}
function buildToolGroup(tools: string[], ids: number[]): DisplayItem {
const counts = new Map<string, number>()
for (const t of tools) counts.set(t, (counts.get(t) || 0) + 1)
return {
type: 'tool-group',
tools: [...counts.entries()].map(([name, count]) => ({
name, count, meta: getToolMeta(name)
})),
ids
}
}
const displayMessages = computed<DisplayItem[]>(() => {
const items: DisplayItem[] = []
let pendingTools: string[] = []
let pendingIds: number[] = []
for (const msg of messages) {
const isToolOnly = msg.toolCalls?.length && msg.content.startsWith('[Tool calls:')
if (isToolOnly) {
pendingTools.push(...msg.toolCalls!)
pendingIds.push(msg.id)
} else {
if (pendingTools.length) {
items.push(buildToolGroup(pendingTools, pendingIds))
pendingTools = []
pendingIds = []
}
items.push({ type: 'message', msg })
}
}
if (pendingTools.length) {
items.push(buildToolGroup(pendingTools, pendingIds))
}
return items
})
// ── Formatting helpers ──
function shortModel(model: string): string {
@@ -241,7 +359,8 @@ function appendTranscriptMessages(newMsgs: any[]) {
role: msg.role === 'user' ? 'user' : 'agent',
content: msg.content || '',
status: 'done',
uuid: msg.uuid
uuid: msg.uuid,
toolCalls: msg.toolCalls || undefined
})
}
@@ -383,7 +502,8 @@ async function loadHistory(sessionId?: string) {
role: msg.role === 'user' ? 'user' : 'agent',
content: msg.content || '',
status: 'done',
uuid: msg.uuid
uuid: msg.uuid,
toolCalls: msg.toolCalls || undefined
})
}
@@ -437,6 +557,8 @@ function handleSessionChange(sessionId: string) {
function handleSubmit(text: string) {
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
emit('submit', text)
// Send text as input to the agent's terminal PTY
agentTerminal.sendPrompt(text)
// Real response will arrive via transcript-update WebSocket
}
@@ -455,6 +577,7 @@ function handleTranscriptDone(text: string) {
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
emit('submit', text)
agentTerminal.sendPrompt(text)
// Real response will arrive via transcript-update WebSocket
}
@@ -467,6 +590,10 @@ function toggleHistory() {
if (showHistory.value) scrollToBottom()
}
function toggleTerminal() {
showTerminal.value = !showTerminal.value
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
@@ -477,6 +604,7 @@ watch(() => props.visible, async (v) => {
showTranscript.value = false
showHistory.value = false
showSettings.value = false
showTerminal.value = false
messages.length = 0
idCounter = 0
sessionStats.value = null
@@ -493,6 +621,10 @@ watch(() => props.visible, async (v) => {
// Connect WebSocket for real-time updates
connectWs()
// Connect agent terminal (lazy)
agentTerminal.connect()
agentTerminal.checkStatus()
await voice.init()
await nextTick()
if (props.startRecording) {
@@ -502,6 +634,8 @@ watch(() => props.visible, async (v) => {
}
} else {
disconnectWs()
agentTerminal.disconnect()
showTerminal.value = false
if (voice.isRecording.value) {
voice.stopRecording()
}
@@ -518,6 +652,7 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
disconnectWs()
agentTerminal.dispose()
voice.cleanup()
})
</script>
@@ -592,86 +727,109 @@ onBeforeUnmount(() => {
<!-- Conversation content area -->
<div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }">
<div
v-for="msg in messages"
:key="msg.id"
class="chat-msg"
:class="msg.role"
>
<template v-if="msg.role === 'user'">
<div class="msg-bubble user-bubble">{{ msg.content }}</div>
</template>
<template v-for="(item, idx) in displayMessages" :key="item.msg?.id || item.ids?.join('-') || idx">
<!-- Tool group: chips compactos -->
<div v-if="item.type === 'tool-group'" class="tool-chips-row">
<span
v-for="tool in item.tools"
:key="tool.name"
class="tool-chip"
:style="{ '--chip-color': tool.meta.color }"
>
<span class="chip-icon">{{ tool.meta.icon }}</span>
<span class="chip-label">{{ tool.meta.label || tool.name }}</span>
<span v-if="tool.count > 1" class="chip-count">{{ tool.count }}</span>
</span>
</div>
<!-- Intervention card -->
<template v-else-if="msg.intervention">
<div class="intervention-card" :class="`intervention--${msg.intervention.type}`">
<!-- Permission card -->
<template v-if="msg.intervention.type === 'permission'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="intv-title">Permission: {{ msg.intervention.toolName }}</span>
</div>
<div class="intv-detail">{{ msg.content }}</div>
<div v-if="!msg.intervention.resolved" class="intv-actions">
<button class="intv-btn intv-btn--allow" @click="respondPermission(msg.id, msg.intervention.requestId!, 'allow')">Allow</button>
<button class="intv-btn intv-btn--deny" @click="respondPermission(msg.id, msg.intervention.requestId!, 'deny')">Deny</button>
</div>
<div v-else class="intv-resolved" :class="msg.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
{{ msg.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
</div>
</template>
<!-- Regular message -->
<div v-else class="chat-msg" :class="item.msg!.role">
<template v-if="item.msg!.role === 'user'">
<div class="msg-bubble user-bubble">{{ item.msg!.content }}</div>
</template>
<!-- Question card (info only) -->
<template v-else-if="msg.intervention.type === 'question'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="intv-title">Question</span>
</div>
<div class="intv-detail">{{ msg.content }}</div>
<div v-if="msg.intervention.options?.length" class="intv-options">
<div v-for="(opt, i) in msg.intervention.options" :key="i" class="intv-option">
<span class="opt-label">{{ opt.label }}</span>
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
<!-- Intervention card -->
<template v-else-if="item.msg!.intervention">
<div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
<!-- Permission card -->
<template v-if="item.msg!.intervention.type === 'permission'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="intv-title">Permission: {{ item.msg!.intervention.toolName }}</span>
</div>
</div>
<div class="intv-hint">Respond in terminal</div>
</template>
<div class="intv-detail">{{ item.msg!.content }}</div>
<div v-if="!item.msg!.intervention.resolved" class="intv-actions">
<button class="intv-btn intv-btn--allow" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'allow')">Allow</button>
<button class="intv-btn intv-btn--deny" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'deny')">Deny</button>
</div>
<div v-else class="intv-resolved" :class="item.msg!.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
{{ item.msg!.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
</div>
</template>
<!-- Plan card (info only) -->
<template v-else-if="msg.intervention.type === 'plan'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span class="intv-title">{{ msg.content }}</span>
</div>
<div class="intv-hint">Review in terminal</div>
</template>
</div>
</template>
<!-- Question card (info only) -->
<template v-else-if="item.msg!.intervention.type === 'question'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="intv-title">Question</span>
</div>
<div class="intv-detail">{{ item.msg!.content }}</div>
<div v-if="item.msg!.intervention.options?.length" class="intv-options">
<div v-for="(opt, i) in item.msg!.intervention.options" :key="i" class="intv-option">
<span class="opt-label">{{ opt.label }}</span>
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
</div>
</div>
<div class="intv-hint">Respond in terminal</div>
</template>
<template v-else>
<div class="msg-bubble agent-bubble">
<div v-if="msg.status === 'thinking'" class="thinking-inline">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<!-- Plan card (info only) -->
<template v-else-if="item.msg!.intervention.type === 'plan'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span class="intv-title">{{ item.msg!.content }}</span>
</div>
<div class="intv-hint">Review in terminal</div>
</template>
</div>
<div v-else class="agent-text fade-in">{{ msg.content }}</div>
</div>
</template>
</div>
</template>
<template v-else>
<div class="msg-bubble agent-bubble">
<div v-if="item.msg!.status === 'thinking'" class="thinking-inline">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<template v-else>
<div class="agent-text fade-in">{{ item.msg!.content }}</div>
<!-- Inline tool chips for mixed messages (text + tools) -->
<div v-if="item.msg!.toolCalls?.length && !item.msg!.content.startsWith('[Tool calls:')" class="inline-tools">
<span
v-for="t in item.msg!.toolCalls"
:key="t"
class="tool-chip-mini"
:style="{ '--chip-color': getToolMeta(t).color }"
>{{ getToolMeta(t).icon }} {{ getToolMeta(t).label || t }}</span>
</div>
</template>
</div>
</template>
</div>
</template>
<TranscriptCard v-if="showTranscript" :voice="voice" @done="handleTranscriptDone" />
<InputSettings
@@ -688,17 +846,27 @@ onBeforeUnmount(() => {
<!-- Input -->
<ChatInput
ref="chatInputEl"
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
:placeholder="inputPlaceholder"
:recording="isRecording"
:history-active="showHistory"
:settings-active="showSettings"
:terminal-active="showTerminal"
:status-dot="statusDot"
:autofocus="visible"
@submit="handleSubmit"
@mic="handleMic"
@toggle-history="toggleHistory"
@toggle-settings="toggleSettings"
@toggle-terminal="toggleTerminal"
/>
</div>
<!-- Agent terminal floating window (shares composable instance) -->
<AgentTerminal
v-model="showTerminal"
:agent="agent"
:agent-terminal="agentTerminal"
/>
</div>
</Transition>
</Teleport>
@@ -1137,6 +1305,74 @@ onBeforeUnmount(() => {
50% { border-color: rgba(239, 68, 68, 0.4); }
}
/* Tool chips row — consecutive tool calls share horizontal space */
.tool-chips-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
animation: msg-in 0.2s ease-out;
max-width: 95%;
}
.tool-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 6px;
font-size: 10px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
background: color-mix(in srgb, var(--chip-color) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
color: var(--chip-color);
white-space: nowrap;
transition: background 0.15s;
}
.tool-chip:hover {
background: color-mix(in srgb, var(--chip-color) 22%, transparent);
}
.chip-icon {
font-size: 10px;
opacity: 0.8;
}
.chip-label {
font-weight: 500;
}
.chip-count {
font-size: 9px;
font-weight: 700;
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
padding: 0 4px;
border-radius: 4px;
min-width: 14px;
text-align: center;
}
/* Reduce gap between consecutive tool rows */
.tool-chips-row + .tool-chips-row {
margin-top: -2px;
}
/* Inline tool chips for mixed messages (text + tool calls) */
.inline-tools {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.tool-chip-mini {
font-size: 9px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
color: var(--chip-color);
opacity: 0.7;
white-space: nowrap;
}
/* Mobile */
@media (max-width: 768px) {
.prompt-bar-panel {