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

@@ -0,0 +1,436 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import type { Agent } from '../../types/agent'
import type { AgentTerminal as AgentTerminalType } from '../../composables/useAgentTerminal'
const props = defineProps<{
modelValue: boolean
agent: Agent
agentTerminal: AgentTerminalType
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const renderer = props.agentTerminal.renderer
const terminalState = props.agentTerminal.terminalState
const connected = props.agentTerminal.connected
const agentRunning = props.agentTerminal.agentRunning
const startAgent = props.agentTerminal.startAgent
const stopAgent = props.agentTerminal.stopAgent
// Local ref for the xterm container - syncs to composable's containerRef
const terminalContainer = ref<HTMLElement | null>(null)
watch(terminalContainer, (el) => {
props.agentTerminal.containerRef.value = el
})
// Drag state
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 560, h: 340 })
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
const windowRef = ref<HTMLElement | null>(null)
// Style
const agentColor = computed(() => props.agent.uiConfig?.color || '#6366f1')
const agentBg = computed(() => props.agent.uiConfig?.terminalBg || '#0f0a1a')
const agentBorder = computed(() => props.agent.uiConfig?.terminalBorder || '#6366f1')
const statusDotClass = computed(() => {
switch (terminalState.value) {
case 'ready': return 'on'
case 'connecting':
case 'agent-starting': return 'wait'
case 'crashed': return 'error'
default: return ''
}
})
const terminalStyle = computed((): Record<string, string> => {
if (!hasCustomPosition.value) {
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
bottom: '80px',
right: '16px'
}
}
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
// ── Drag ──
function startDrag(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
isDragging.value = true
const rect = windowRef.value?.getBoundingClientRect()
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
const w = windowRef.value?.offsetWidth || 560
const h = windowRef.value?.offsetHeight || 340
position.value = {
x: Math.max(-w * 0.75, Math.min(e.clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
y: Math.max(-h * 0.75, Math.min(e.clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// ── Resize ──
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => renderer.fit())
}
// ── Actions ──
function close() {
isOpen.value = false
}
function clearBuffer() {
renderer.reset()
}
function handleRestart() {
startAgent(true)
}
// ── Watch open/close to init terminal ──
watch(isOpen, (open) => {
if (open) {
nextTick(() => {
// Initialize the renderer if not already done (container ref is now set)
if (!renderer.isReady.value) {
renderer.init()
}
// Wait for CSS transition then fit + focus
setTimeout(() => {
renderer.onBecameVisible()
}, 150)
})
} else {
renderer.blur()
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<template>
<Teleport to="body">
<Transition name="at-slide">
<div
v-show="isOpen"
ref="windowRef"
class="agent-terminal"
:class="{ dragging: isDragging, resizing: isResizing }"
:style="terminalStyle"
>
<div class="at-glass" :style="{ borderColor: agentBorder + '40' }">
<!-- Titlebar -->
<div class="at-titlebar" @mousedown="startDrag">
<div class="at-left">
<div
class="at-badge"
:style="{ background: agent.uiConfig?.gradient || agentColor }"
>{{ agent.uiConfig?.shortLabel || agent.id[0]?.toUpperCase() }}</div>
<span class="at-name">{{ agent.uiConfig?.label || agent.name }}</span>
<i class="at-dot" :class="statusDotClass"></i>
<span v-if="terminalState === 'agent-starting'" class="at-status-text">Starting...</span>
<span v-else-if="terminalState === 'connecting'" class="at-status-text">Connecting...</span>
<span v-else-if="terminalState === 'crashed'" class="at-status-text crashed">Crashed</span>
</div>
<div class="window-controls">
<button
v-if="terminalState === 'crashed' || terminalState === 'off'"
class="wc-btn start"
title="Start Agent"
@click.stop="handleRestart"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button
v-if="agentRunning"
class="wc-btn restart"
title="Restart Agent"
@click.stop="handleRestart"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
</button>
<button
class="wc-btn"
title="Clear Buffer"
@click.stop="clearBuffer"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14"/></svg>
</button>
<button class="wc-btn x" title="Close" @click.stop="close">
<svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</div>
</div>
<!-- Terminal content -->
<div class="at-content" :style="{ background: agentBg }">
<div ref="terminalContainer" class="at-term"></div>
<!-- Overlay states -->
<div v-if="terminalState === 'off'" class="at-overlay" @click="agentTerminal.connect()">
<div class="at-overlay-msg">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
<span>Agent offline</span>
<small>Click to connect</small>
</div>
</div>
<div v-else-if="terminalState === 'connecting'" class="at-overlay connecting">
<div class="at-overlay-msg">
<div class="at-spinner"></div>
<span>Connecting...</span>
</div>
</div>
</div>
<!-- Resize handle -->
<div class="at-resize" @mousedown="startResize"></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.agent-terminal {
position: fixed;
min-width: 400px;
min-height: 250px;
z-index: 10001;
}
.at-glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(200, 215, 235, 0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 0 0 1px rgba(80, 120, 180, 0.25), 0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.at-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 24px;
padding: 0 4px 0 6px;
background: rgba(255, 255, 255, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
cursor: grab;
user-select: none;
}
.agent-terminal.dragging .at-titlebar { cursor: grabbing; }
.at-left {
display: flex;
align-items: center;
gap: 6px;
font: 500 10px/1 system-ui, sans-serif;
color: #222;
}
.at-badge {
width: 16px;
height: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 8px;
font-weight: 700;
flex-shrink: 0;
}
.at-name {
font-weight: 600;
font-size: 11px;
color: #333;
}
.at-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #999;
flex-shrink: 0;
}
.at-dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
.at-dot.wait { background: #a80; animation: at-pulse 0.8s infinite; }
.at-dot.error { background: #e44; box-shadow: 0 0 4px #e44; }
.at-status-text {
font-size: 9px;
color: #666;
}
.at-status-text.crashed { color: #c33; }
.window-controls { display: flex; gap: 1px; }
.wc-btn {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 2px;
color: #333;
cursor: pointer;
}
.wc-btn:hover { background: rgba(255, 255, 255, 0.5); }
.wc-btn.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
.wc-btn.start { color: #0a0; }
.wc-btn.start:hover { background: rgba(16, 185, 129, 0.3); }
.wc-btn.restart:hover { background: rgba(245, 158, 11, 0.3); color: #a80; }
.at-content {
flex: 1;
margin: 2px;
border-radius: 2px;
overflow: hidden;
position: relative;
}
.at-term { width: 100%; height: 100%; }
.at-term :deep(.xterm) { height: 100%; padding: 2px; }
.at-term :deep(.xterm-viewport) {
overflow-y: auto !important;
scrollbar-width: thin;
}
.at-term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0, 0, 0, 0.2); }
.at-term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
.at-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
cursor: pointer;
}
.at-overlay.connecting { cursor: wait; }
.at-overlay-msg {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff;
text-align: center;
}
.at-overlay-msg svg { color: #ef4444; opacity: 0.8; }
.at-overlay-msg span { font-size: 13px; font-weight: 500; }
.at-overlay-msg small { font-size: 10px; color: #888; }
.at-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: at-spin 0.8s linear infinite;
}
.at-resize {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 100%);
border-radius: 0 0 5px 0;
}
.at-resize:hover { background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.2) 100%); }
.agent-terminal.resizing { user-select: none; }
.agent-terminal.resizing .at-term { pointer-events: none; }
.at-slide-enter-active, .at-slide-leave-active { transition: all 0.15s ease; }
.at-slide-enter-from, .at-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
@keyframes at-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes at-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -6,7 +6,10 @@ const props = defineProps<{
recording?: boolean
historyActive?: boolean
settingsActive?: boolean
terminalActive?: boolean
disabled?: boolean
autofocus?: boolean
statusDot?: 'green' | 'yellow' | 'red' | 'gray' | ''
}>()
const emit = defineEmits<{
@@ -14,6 +17,7 @@ const emit = defineEmits<{
mic: []
'toggle-history': []
'toggle-settings': []
'toggle-terminal': []
}>()
const inputText = ref('')
@@ -42,12 +46,14 @@ defineExpose({ focus })
<template>
<div class="chat-input-row">
<i v-if="statusDot" class="ci-status-dot" :class="statusDot"></i>
<input
ref="inputEl"
v-model="inputText"
class="chat-input"
type="text"
:placeholder="placeholder || 'Escribe un mensaje...'"
:disabled="disabled"
@keydown.enter="handleSubmit"
/>
<button
@@ -95,6 +101,17 @@ defineExpose({ focus })
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button
class="ci-btn ci-terminal"
:class="{ active: terminalActive }"
title="Terminal"
@click="emit('toggle-terminal')"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</button>
</div>
</template>
@@ -162,11 +179,39 @@ defineExpose({ focus })
}
.ci-history.active,
.ci-settings.active {
.ci-settings.active,
.ci-terminal.active {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
}
.ci-terminal:hover {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.25);
color: rgba(16, 185, 129, 0.9);
}
.ci-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ci-status-dot.green { background: #22c55e; box-shadow: 0 0 4px #22c55e; }
.ci-status-dot.yellow { background: #eab308; box-shadow: 0 0 4px #eab308; animation: ci-pulse 1s infinite; }
.ci-status-dot.red { background: #ef4444; box-shadow: 0 0 4px #ef4444; }
.ci-status-dot.gray { background: #6b7280; }
.chat-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes ci-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }

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 {