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

@@ -345,6 +345,7 @@ onBeforeUnmount(() => {
<PromptBar
v-if="activeAgent"
:key="activeAgent.id"
ref="promptBarRef"
:agent="activeAgent"
:anchor-rect="activeAnchorRect"

View File

@@ -0,0 +1,464 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useClaudeHooksStore } from '../stores/claude-hooks'
import { useCanvasStore } from '../stores/canvas'
interface LogEntry {
id: string
source: 'hook' | 'canvas' | 'response'
type: 'info' | 'success' | 'warning' | 'error'
title: string
detail: string
timestamp: number
}
const STORAGE_KEY = 'notification-log'
const MAX_ENTRIES = 500
const hooksStore = useClaudeHooksStore()
const canvasStore = useCanvasStore()
const isOpen = ref(false)
const entries = ref<LogEntry[]>(loadFromStorage())
const filter = ref<'all' | 'hook' | 'canvas' | 'response'>('all')
// Track what we've already logged to avoid duplicates
const seenHookIds = new Set<string>()
const seenCanvasIds = new Set<number>()
// Seed seen IDs from existing entries on load
entries.value.forEach(e => {
if (e.source === 'hook') seenHookIds.add(e.id)
})
function loadFromStorage(): LogEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.value.slice(-MAX_ENTRIES)))
} catch { /* quota exceeded — silently ignore */ }
}
function addEntry(entry: LogEntry) {
entries.value.push(entry)
if (entries.value.length > MAX_ENTRIES) {
entries.value = entries.value.slice(-MAX_ENTRIES)
}
saveToStorage()
}
function clearAll() {
entries.value = []
seenHookIds.clear()
seenCanvasIds.clear()
localStorage.removeItem(STORAGE_KEY)
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString('es', { day: '2-digit', month: 'short' })
}
// Group entries by date
function getFilteredEntries() {
if (filter.value === 'all') return entries.value
return entries.value.filter(e => e.source === filter.value)
}
// Watch hook notifications
watch(() => hooksStore.notifications, (notifs) => {
for (const n of notifs) {
if (seenHookIds.has(n.id)) continue
seenHookIds.add(n.id)
addEntry({
id: n.id,
source: 'hook',
type: n.type,
title: n.title,
detail: n.detail,
timestamp: n.timestamp
})
}
}, { deep: true })
// Watch canvas notifications
watch(() => canvasStore.notifications, (notifs) => {
for (const n of notifs) {
if (seenCanvasIds.has(n.id)) continue
seenCanvasIds.add(n.id)
addEntry({
id: `canvas_${n.id}`,
source: 'canvas',
type: n.type as LogEntry['type'],
title: 'App',
detail: n.message,
timestamp: Date.now()
})
}
}, { deep: true })
// Expose addResponseEntry for external use (FloatingResponse)
function addResponseEntry(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
addEntry({
id: `resp_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`,
source: 'response',
type,
title: 'Agent Response',
detail: message.length > 300 ? message.slice(0, 300) + '...' : message,
timestamp: Date.now()
})
}
defineExpose({ addResponseEntry })
const count = ref(0)
watch(entries, (e) => { count.value = e.length }, { immediate: true })
const sourceLabels: Record<string, string> = {
hook: 'Hook',
canvas: 'App',
response: 'Response'
}
const typeColors: Record<string, string> = {
info: '#6366f1',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
}
</script>
<template>
<Teleport to="body">
<!-- Toggle button -->
<button
class="notif-log-toggle"
:class="{ open: isOpen }"
@click="isOpen = !isOpen"
title="Notification Log"
>
<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">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span v-if="count > 0" class="notif-badge">{{ count > 99 ? '99+' : count }}</span>
</button>
<!-- Panel -->
<Transition name="notif-slide">
<div v-if="isOpen" class="notif-log-panel">
<div class="notif-log-header">
<span class="notif-log-title">Notifications ({{ getFilteredEntries().length }})</span>
<div class="notif-log-actions">
<select v-model="filter" class="notif-filter">
<option value="all">All</option>
<option value="hook">Hooks</option>
<option value="canvas">App</option>
<option value="response">Response</option>
</select>
<button @click="clearAll" class="notif-clear" title="Clear all">Clear</button>
<button @click="isOpen = false" class="notif-close">&times;</button>
</div>
</div>
<div class="notif-log-body">
<template v-if="getFilteredEntries().length">
<div
v-for="entry in [...getFilteredEntries()].reverse()"
:key="entry.id"
class="notif-entry"
>
<div class="notif-entry-accent" :style="{ background: typeColors[entry.type] }"></div>
<div class="notif-entry-content">
<div class="notif-entry-top">
<span class="notif-source" :class="entry.source">{{ sourceLabels[entry.source] || entry.source }}</span>
<span class="notif-type-dot" :style="{ background: typeColors[entry.type] }"></span>
<span class="notif-entry-title">{{ entry.title }}</span>
<span class="notif-entry-time">{{ formatTime(entry.timestamp) }}</span>
<span class="notif-entry-date">{{ formatDate(entry.timestamp) }}</span>
</div>
<div v-if="entry.detail" class="notif-entry-detail">{{ entry.detail }}</div>
</div>
</div>
</template>
<div v-else class="notif-empty">No notifications yet</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.notif-log-toggle {
position: fixed;
top: 8px;
right: 80px;
width: 30px;
height: 30px;
border-radius: 8px;
background: rgba(40, 40, 50, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #999;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10003;
transition: all 0.2s ease;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.notif-log-toggle:hover {
background: rgba(60, 60, 75, 0.95);
color: #ddd;
border-color: rgba(99, 102, 241, 0.4);
}
.notif-log-toggle.open {
background: rgba(99, 102, 241, 0.25);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.5);
}
.notif-badge {
position: absolute;
top: -4px;
right: -4px;
background: #ef4444;
color: white;
font-size: 8px;
font-weight: 700;
padding: 1px 4px;
border-radius: 10px;
min-width: 14px;
text-align: center;
line-height: 1.3;
}
/* Panel */
.notif-log-panel {
position: fixed;
top: 44px;
right: 12px;
width: 380px;
max-height: calc(100vh - 100px);
background: rgba(18, 18, 24, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
z-index: 10003;
display: flex;
flex-direction: column;
backdrop-filter: blur(12px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
.notif-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.notif-log-title {
font-size: 12px;
font-weight: 600;
color: #ccc;
}
.notif-log-actions {
display: flex;
align-items: center;
gap: 6px;
}
.notif-filter {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #aaa;
font-size: 10px;
padding: 2px 4px;
cursor: pointer;
outline: none;
}
.notif-filter option {
background: #1a1a24;
}
.notif-clear,
.notif-close {
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 4px;
color: #888;
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: all 0.15s;
}
.notif-close {
font-size: 16px;
padding: 0 6px;
line-height: 1;
}
.notif-clear:hover { background: rgba(239, 68, 68, 0.25); color: #fca5a5; }
.notif-close:hover { background: rgba(255, 255, 255, 0.15); color: #ddd; }
/* Body */
.notif-log-body {
flex: 1;
overflow-y: auto;
padding: 6px;
max-height: calc(100vh - 180px);
}
.notif-log-body::-webkit-scrollbar {
width: 4px;
}
.notif-log-body::-webkit-scrollbar-track {
background: transparent;
}
.notif-log-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
/* Entry */
.notif-entry {
display: flex;
gap: 0;
margin-bottom: 2px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
transition: background 0.15s;
}
.notif-entry:hover {
background: rgba(255, 255, 255, 0.06);
}
.notif-entry-accent {
width: 3px;
flex-shrink: 0;
}
.notif-entry-content {
flex: 1;
padding: 6px 8px;
min-width: 0;
}
.notif-entry-top {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
}
.notif-source {
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.notif-source.hook {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
.notif-source.canvas {
background: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
}
.notif-source.response {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.notif-type-dot {
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.notif-entry-title {
color: #ccc;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.notif-entry-time {
color: #555;
font-size: 10px;
flex-shrink: 0;
font-family: 'Consolas', 'Monaco', monospace;
}
.notif-entry-date {
color: #444;
font-size: 9px;
flex-shrink: 0;
}
.notif-entry-detail {
color: #777;
font-size: 10px;
margin-top: 2px;
word-break: break-word;
line-height: 1.4;
}
.notif-empty {
text-align: center;
color: #555;
padding: 40px 20px;
font-size: 12px;
}
/* Transitions */
.notif-slide-enter-active,
.notif-slide-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.notif-slide-enter-from,
.notif-slide-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.97);
}
@media (max-width: 480px) {
.notif-log-panel {
left: 8px;
right: 8px;
width: auto;
}
}
</style>

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 {