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

@@ -5,6 +5,6 @@
"repo": "anthropics/claude-plugins-official" "repo": "anthropics/claude-plugins-official"
}, },
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official", "installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
"lastUpdated": "2026-02-15T19:50:55.853Z" "lastUpdated": "2026-02-16T06:32:07.237Z"
} }
} }

View File

@@ -26,7 +26,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000 "timeout": 5000
} }
] ]
@@ -38,7 +38,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000 "timeout": 5000
} }
] ]
@@ -50,7 +50,18 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000 "timeout": 5000
} }
] ]
@@ -62,7 +73,19 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000
}
]
}
],
"PermissionRequest": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000 "timeout": 5000
} }
] ]
@@ -73,8 +96,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
"timeout": 5000 "timeout": 10000
} }
] ]
} }

View File

@@ -5,5 +5,5 @@
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)", "gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
"terminalBg": "#0f0a1a", "terminalBg": "#0f0a1a",
"terminalBorder": "#6366f1", "terminalBorder": "#6366f1",
"enabled": false "enabled": true
} }

View File

@@ -8,6 +8,7 @@ import FloatingResponse from './components/FloatingResponse.vue'
import FloatingVoice from './components/FloatingVoice.vue' import FloatingVoice from './components/FloatingVoice.vue'
import AgentBar from './components/AgentBar.vue' import AgentBar from './components/AgentBar.vue'
import HookNotifications from './components/HookNotifications.vue' import HookNotifications from './components/HookNotifications.vue'
import NotificationLog from './components/NotificationLog.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue'
import { initWebMCP, getWebMCP } from './services/webmcp' import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch' import { initTorch, destroyTorch } from './services/torch'
@@ -67,6 +68,7 @@ function clearDebugLogs() {
} }
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null) const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null) const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const notifLogRef = ref<InstanceType<typeof NotificationLog> | null>(null)
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null) const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore() const projectCanvasStore = useProjectCanvasStore()
@@ -330,6 +332,8 @@ onMounted(async () => {
// Setup response controls for MCP tools // Setup response controls for MCP tools
setResponseControls({ setResponseControls({
addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => { addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
// Also log to notification log
notifLogRef.value?.addResponseEntry(message, type || 'info')
if (responseRef.value) { if (responseRef.value) {
return responseRef.value.addMessage(message, type) return responseRef.value.addMessage(message, type)
} }
@@ -544,6 +548,9 @@ watch(() => route.name, (newPage) => {
<!-- Hook Notifications (toasts from Claude Code hooks) --> <!-- Hook Notifications (toasts from Claude Code hooks) -->
<HookNotifications /> <HookNotifications />
<!-- Notification Log (temporary - collects all notifications, persists to localStorage) -->
<NotificationLog ref="notifLogRef" />
<!-- Floating Voice Input --> <!-- Floating Voice Input -->
<FloatingVoice ref="voiceRef" v-model="showVoice" /> <FloatingVoice ref="voiceRef" v-model="showVoice" />

View File

@@ -345,6 +345,7 @@ onBeforeUnmount(() => {
<PromptBar <PromptBar
v-if="activeAgent" v-if="activeAgent"
:key="activeAgent.id"
ref="promptBarRef" ref="promptBarRef"
:agent="activeAgent" :agent="activeAgent"
:anchor-rect="activeAnchorRect" :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 recording?: boolean
historyActive?: boolean historyActive?: boolean
settingsActive?: boolean settingsActive?: boolean
terminalActive?: boolean
disabled?: boolean
autofocus?: boolean autofocus?: boolean
statusDot?: 'green' | 'yellow' | 'red' | 'gray' | ''
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -14,6 +17,7 @@ const emit = defineEmits<{
mic: [] mic: []
'toggle-history': [] 'toggle-history': []
'toggle-settings': [] 'toggle-settings': []
'toggle-terminal': []
}>() }>()
const inputText = ref('') const inputText = ref('')
@@ -42,12 +46,14 @@ defineExpose({ focus })
<template> <template>
<div class="chat-input-row"> <div class="chat-input-row">
<i v-if="statusDot" class="ci-status-dot" :class="statusDot"></i>
<input <input
ref="inputEl" ref="inputEl"
v-model="inputText" v-model="inputText"
class="chat-input" class="chat-input"
type="text" type="text"
:placeholder="placeholder || 'Escribe un mensaje...'" :placeholder="placeholder || 'Escribe un mensaje...'"
:disabled="disabled"
@keydown.enter="handleSubmit" @keydown.enter="handleSubmit"
/> />
<button <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"/> <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> </svg>
</button> </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> </div>
</template> </template>
@@ -162,11 +179,39 @@ defineExpose({ focus })
} }
.ci-history.active, .ci-history.active,
.ci-settings.active { .ci-settings.active,
.ci-terminal.active {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7); 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 { @keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); } 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); } 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 type { Agent } from '../../types/agent'
import { endpoints } from '../../config/endpoints' import { endpoints } from '../../config/endpoints'
import { useVoiceCapture } from '../../composables/useVoiceCapture' import { useVoiceCapture } from '../../composables/useVoiceCapture'
import { useAgentTerminal } from '../../composables/useAgentTerminal'
import { useCanvasStore } from '../../stores/canvas' import { useCanvasStore } from '../../stores/canvas'
import ChatInput from './ChatInput.vue' import ChatInput from './ChatInput.vue'
import TranscriptCard from './TranscriptCard.vue' import TranscriptCard from './TranscriptCard.vue'
import InputSettings from './InputSettings.vue' import InputSettings from './InputSettings.vue'
import ConversationHistory from './ConversationHistory.vue' import ConversationHistory from './ConversationHistory.vue'
import AgentTerminal from './AgentTerminal.vue'
interface ClaudeUsage { interface ClaudeUsage {
subscription: { type: string; tier: string; label: string; multiplier: number } subscription: { type: string; tier: string; label: string; multiplier: number }
@@ -23,6 +25,7 @@ interface ChatMessage {
content: string content: string
status: 'sent' | 'thinking' | 'done' status: 'sent' | 'thinking' | 'done'
uuid?: string uuid?: string
toolCalls?: string[]
intervention?: { intervention?: {
type: 'permission' | 'question' | 'plan' type: 'permission' | 'question' | 'plan'
requestId?: string requestId?: string
@@ -60,6 +63,32 @@ const voice = useVoiceCapture({
onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration) 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 contentEl = ref<HTMLDivElement | null>(null)
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null) const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
const isRecording = ref(false) const isRecording = ref(false)
@@ -96,7 +125,7 @@ const panelStyle = computed(() => {
const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2 const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2
const bottomOffset = window.innerHeight - props.anchorRect.top + 12 const bottomOffset = window.innerHeight - props.anchorRect.top + 12
const panelWidth = 360 const panelWidth = 420
let left = bubbleCenterX - panelWidth / 2 let left = bubbleCenterX - panelWidth / 2
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12)) 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 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 ── // ── Formatting helpers ──
function shortModel(model: string): string { function shortModel(model: string): string {
@@ -241,7 +359,8 @@ function appendTranscriptMessages(newMsgs: any[]) {
role: msg.role === 'user' ? 'user' : 'agent', role: msg.role === 'user' ? 'user' : 'agent',
content: msg.content || '', content: msg.content || '',
status: 'done', 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', role: msg.role === 'user' ? 'user' : 'agent',
content: msg.content || '', content: msg.content || '',
status: 'done', status: 'done',
uuid: msg.uuid uuid: msg.uuid,
toolCalls: msg.toolCalls || undefined
}) })
} }
@@ -437,6 +557,8 @@ function handleSessionChange(sessionId: string) {
function handleSubmit(text: string) { function handleSubmit(text: string) {
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' }) messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
emit('submit', text) emit('submit', text)
// Send text as input to the agent's terminal PTY
agentTerminal.sendPrompt(text)
// Real response will arrive via transcript-update WebSocket // 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' }) messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
emit('submit', text) emit('submit', text)
agentTerminal.sendPrompt(text)
// Real response will arrive via transcript-update WebSocket // Real response will arrive via transcript-update WebSocket
} }
@@ -467,6 +590,10 @@ function toggleHistory() {
if (showHistory.value) scrollToBottom() if (showHistory.value) scrollToBottom()
} }
function toggleTerminal() {
showTerminal.value = !showTerminal.value
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close') if (e.key === 'Escape') emit('close')
} }
@@ -477,6 +604,7 @@ watch(() => props.visible, async (v) => {
showTranscript.value = false showTranscript.value = false
showHistory.value = false showHistory.value = false
showSettings.value = false showSettings.value = false
showTerminal.value = false
messages.length = 0 messages.length = 0
idCounter = 0 idCounter = 0
sessionStats.value = null sessionStats.value = null
@@ -493,6 +621,10 @@ watch(() => props.visible, async (v) => {
// Connect WebSocket for real-time updates // Connect WebSocket for real-time updates
connectWs() connectWs()
// Connect agent terminal (lazy)
agentTerminal.connect()
agentTerminal.checkStatus()
await voice.init() await voice.init()
await nextTick() await nextTick()
if (props.startRecording) { if (props.startRecording) {
@@ -502,6 +634,8 @@ watch(() => props.visible, async (v) => {
} }
} else { } else {
disconnectWs() disconnectWs()
agentTerminal.disconnect()
showTerminal.value = false
if (voice.isRecording.value) { if (voice.isRecording.value) {
voice.stopRecording() voice.stopRecording()
} }
@@ -518,6 +652,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
disconnectWs() disconnectWs()
agentTerminal.dispose()
voice.cleanup() voice.cleanup()
}) })
</script> </script>
@@ -592,86 +727,109 @@ onBeforeUnmount(() => {
<!-- Conversation content area --> <!-- Conversation content area -->
<div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }"> <div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }">
<div <template v-for="(item, idx) in displayMessages" :key="item.msg?.id || item.ids?.join('-') || idx">
v-for="msg in messages" <!-- Tool group: chips compactos -->
:key="msg.id" <div v-if="item.type === 'tool-group'" class="tool-chips-row">
class="chat-msg" <span
:class="msg.role" v-for="tool in item.tools"
> :key="tool.name"
<template v-if="msg.role === 'user'"> class="tool-chip"
<div class="msg-bubble user-bubble">{{ msg.content }}</div> :style="{ '--chip-color': tool.meta.color }"
</template> >
<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 --> <!-- Regular message -->
<template v-else-if="msg.intervention"> <div v-else class="chat-msg" :class="item.msg!.role">
<div class="intervention-card" :class="`intervention--${msg.intervention.type}`"> <template v-if="item.msg!.role === 'user'">
<!-- Permission card --> <div class="msg-bubble user-bubble">{{ item.msg!.content }}</div>
<template v-if="msg.intervention.type === 'permission'"> </template>
<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>
<!-- Question card (info only) --> <!-- Intervention card -->
<template v-else-if="msg.intervention.type === 'question'"> <template v-else-if="item.msg!.intervention">
<div class="intv-header"> <div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <!-- Permission card -->
<circle cx="12" cy="12" r="10"/> <template v-if="item.msg!.intervention.type === 'permission'">
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <div class="intv-header">
<line x1="12" y1="17" x2="12.01" y2="17"/> <svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
</svg> <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"/>
<span class="intv-title">Question</span> <line x1="12" y1="9" x2="12" y2="13"/>
</div> <line x1="12" y1="17" x2="12.01" y2="17"/>
<div class="intv-detail">{{ msg.content }}</div> </svg>
<div v-if="msg.intervention.options?.length" class="intv-options"> <span class="intv-title">Permission: {{ item.msg!.intervention.toolName }}</span>
<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>
</div> </div>
</div> <div class="intv-detail">{{ item.msg!.content }}</div>
<div class="intv-hint">Respond in terminal</div> <div v-if="!item.msg!.intervention.resolved" class="intv-actions">
</template> <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) --> <!-- Question card (info only) -->
<template v-else-if="msg.intervention.type === 'plan'"> <template v-else-if="item.msg!.intervention.type === 'question'">
<div class="intv-header"> <div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <circle cx="12" cy="12" r="10"/>
<polyline points="14 2 14 8 20 8"/> <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="16" y1="13" x2="8" y2="13"/> <line x1="12" y1="17" x2="12.01" y2="17"/>
<line x1="16" y1="17" x2="8" y2="17"/> </svg>
</svg> <span class="intv-title">Question</span>
<span class="intv-title">{{ msg.content }}</span> </div>
</div> <div class="intv-detail">{{ item.msg!.content }}</div>
<div class="intv-hint">Review in terminal</div> <div v-if="item.msg!.intervention.options?.length" class="intv-options">
</template> <div v-for="(opt, i) in item.msg!.intervention.options" :key="i" class="intv-option">
</div> <span class="opt-label">{{ opt.label }}</span>
</template> <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> <!-- Plan card (info only) -->
<div class="msg-bubble agent-bubble"> <template v-else-if="item.msg!.intervention.type === 'plan'">
<div v-if="msg.status === 'thinking'" class="thinking-inline"> <div class="intv-header">
<span class="dot"></span> <svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<span class="dot"></span> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<span class="dot"></span> <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>
<div v-else class="agent-text fade-in">{{ msg.content }}</div> </template>
</div>
</template> <template v-else>
</div> <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" /> <TranscriptCard v-if="showTranscript" :voice="voice" @done="handleTranscriptDone" />
<InputSettings <InputSettings
@@ -688,17 +846,27 @@ onBeforeUnmount(() => {
<!-- Input --> <!-- Input -->
<ChatInput <ChatInput
ref="chatInputEl" ref="chatInputEl"
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`" :placeholder="inputPlaceholder"
:recording="isRecording" :recording="isRecording"
:history-active="showHistory" :history-active="showHistory"
:settings-active="showSettings" :settings-active="showSettings"
:terminal-active="showTerminal"
:status-dot="statusDot"
:autofocus="visible" :autofocus="visible"
@submit="handleSubmit" @submit="handleSubmit"
@mic="handleMic" @mic="handleMic"
@toggle-history="toggleHistory" @toggle-history="toggleHistory"
@toggle-settings="toggleSettings" @toggle-settings="toggleSettings"
@toggle-terminal="toggleTerminal"
/> />
</div> </div>
<!-- Agent terminal floating window (shares composable instance) -->
<AgentTerminal
v-model="showTerminal"
:agent="agent"
:agent-terminal="agentTerminal"
/>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
@@ -1137,6 +1305,74 @@ onBeforeUnmount(() => {
50% { border-color: rgba(239, 68, 68, 0.4); } 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 */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.prompt-bar-panel { .prompt-bar-panel {

View File

@@ -0,0 +1,401 @@
/**
* useAgentTerminal
*
* Composable for managing a per-agent terminal session.
* Wraps useTerminalRenderer with agent-specific WebSocket connection,
* agent lifecycle (start/stop), and prompt sending.
*/
import { ref, computed, type Ref } from 'vue'
import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer'
import { agentTerminalUrl, terminalApiUrl } from '../config/endpoints'
export type AgentTerminalState = 'off' | 'connecting' | 'ready' | 'crashed' | 'agent-starting'
export interface AgentTerminal {
// State
terminalState: Ref<AgentTerminalState>
connected: Ref<boolean>
agentRunning: Ref<boolean>
sessionId: Ref<string | null>
// Container ref - bind this to the xterm DOM element in the component
containerRef: Ref<HTMLElement | null>
// Terminal renderer (for mounting xterm)
renderer: TerminalRenderer
// Connection
connect: () => void
disconnect: () => void
// Agent lifecycle
startAgent: (force?: boolean) => Promise<void>
stopAgent: () => Promise<void>
checkStatus: () => Promise<void>
// Prompt
sendPrompt: (text: string) => void
// Cleanup
dispose: () => void
}
export function useAgentTerminal(agentId: string): AgentTerminal {
const containerRef = ref<HTMLElement | null>(null)
const connected = ref(false)
const connecting = ref(false)
const agentRunning = ref(false)
const agentStarting = ref(false)
const crashed = ref(false)
const sessionId = ref<string | null>(null)
let socket: WebSocket | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
let pendingPrompt: string | null = null
const MAX_RECONNECT = 10
const RECONNECT_DELAY = 2000
const terminalState = computed<AgentTerminalState>(() => {
if (agentStarting.value) return 'agent-starting'
if (crashed.value) return 'crashed'
if (connected.value && agentRunning.value) return 'ready'
if (connecting.value) return 'connecting'
return 'off'
})
// Terminal renderer
const renderer = useTerminalRenderer({
container: containerRef,
onData: (data) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
},
onResize: (cols, rows) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
}
},
onKeyEvent: (e) => {
// Ctrl+V: Paste
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
e.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: text }))
}
}).catch(console.error)
return false
}
// Ctrl+C: Copy selection
if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') {
const selection = renderer.getSelection()
if (selection) {
navigator.clipboard.writeText(selection).catch(console.error)
return false
}
}
return true
}
})
// ── WebSocket connection ──
function connect() {
if (connecting.value || connected.value) return
connecting.value = true
crashed.value = false
const wsUrl = agentTerminalUrl(agentId)
console.log(`[AgentTerminal:${agentId}] Connecting to ${wsUrl}`)
const timeout = window.setTimeout(() => {
if (connecting.value && !connected.value) {
connecting.value = false
socket?.close()
socket = null
scheduleReconnect()
}
}, 10000)
try {
socket = new WebSocket(wsUrl)
socket.addEventListener('open', () => clearTimeout(timeout), { once: true })
socket.onopen = () => {
connected.value = true
connecting.value = false
reconnectAttempts = 0
// Send initial resize
const term = renderer.terminal.value
if (term) {
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
handleMessage(msg)
} catch { /* ignore parse errors */ }
}
socket.onclose = () => {
connected.value = false
connecting.value = false
socket = null
if (reconnectAttempts < MAX_RECONNECT) {
scheduleReconnect()
}
}
socket.onerror = () => {
connecting.value = false
}
} catch {
connecting.value = false
scheduleReconnect()
}
}
function disconnect() {
cancelReconnect()
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
connected.value = false
connecting.value = false
}
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
reconnectAttempts++
const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5)
reconnectTimeout = window.setTimeout(() => {
if (!connected.value && !connecting.value) {
connect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
reconnectAttempts = 0
}
// ── Message handling ──
function handleMessage(msg: any) {
switch (msg.type) {
case 'connected':
sessionId.value = msg.sessionId
if (msg.hasHistory) {
// Session has existing output, request replay
setTimeout(() => requestReplay(), 50)
// Check if agent is actually running
checkStatus()
} else if (msg.isNew) {
// Brand new session, auto-start agent
autoStartAgent()
}
break
case 'replay':
renderer.handleReplay(msg.data || '')
break
case 'output':
renderer.write(msg.data)
break
case 'exit':
renderer.write(msg.data)
agentRunning.value = false
crashed.value = true
agentStarting.value = false
sessionId.value = null
break
case 'error':
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
break
case 'session-restart':
renderer.reset()
renderer.writeln('\x1b[33m[Session restarting...]\x1b[0m')
break
case 'claude-status':
if (matchesAgent(msg.agent)) {
if (msg.status === 'sessionStart') {
agentRunning.value = true
agentStarting.value = false
crashed.value = false
}
}
break
case 'buffer-cleared':
break
}
}
function matchesAgent(name: string): boolean {
if (!name) return agentId === 'main'
return name === agentId || name === 'main' && agentId === 'main'
}
function requestReplay(tailOnly = true) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'request-replay', tailOnly, chunks: 200 }))
}
}
// ── Agent lifecycle ──
async function autoStartAgent() {
agentStarting.value = true
try {
await startAgent()
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Auto-start failed:`, e)
agentStarting.value = false
}
}
async function startAgent(force = false) {
agentStarting.value = true
crashed.value = false
try {
const res = await fetch(terminalApiUrl('/start-agent'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId, force })
})
if (!res.ok) { agentStarting.value = false; return }
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) { agentStarting.value = false; return }
const data = await res.json()
if (data.success) {
agentRunning.value = true
// agentStarting stays true until sessionStart is received
// Flush pending prompt
if (pendingPrompt) {
setTimeout(() => {
flushPendingPrompt()
}, 500)
}
} else {
agentStarting.value = false
}
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Start failed:`, e)
agentStarting.value = false
}
}
async function stopAgent() {
try {
const res = await fetch(terminalApiUrl('/stop-agent'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId })
})
if (res.ok) {
agentRunning.value = false
agentStarting.value = false
}
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Stop failed:`, e)
}
}
async function checkStatus() {
try {
const res = await fetch(terminalApiUrl('/agent-sessions'))
if (!res.ok) return
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) return
const data = await res.json()
const state = data[agentId]
if (state) {
agentRunning.value = state.isAgentRunning
if (!state.isAgentRunning && state.sessionExists) {
// Session exists but agent exited
crashed.value = true
}
}
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Status check failed:`, e)
}
}
// ── Prompt sending ──
function typeTextToSocket(text: string) {
const chars = (text + '\r').split('')
let i = 0
const typeChar = () => {
if (i < chars.length && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: chars[i] }))
i++
setTimeout(typeChar, 15)
}
}
typeChar()
}
function sendPrompt(text: string) {
if (socket?.readyState === WebSocket.OPEN && (agentRunning.value || agentStarting.value)) {
typeTextToSocket(text)
} else if (!connected.value) {
// Queue prompt and auto-connect + start
pendingPrompt = text
connect()
} else if (connected.value && !agentRunning.value) {
// Connected but agent not running, start it and queue
pendingPrompt = text
startAgent()
}
}
function flushPendingPrompt() {
if (pendingPrompt && socket?.readyState === WebSocket.OPEN) {
typeTextToSocket(pendingPrompt)
pendingPrompt = null
}
}
// ── Cleanup ──
function dispose() {
disconnect()
renderer.dispose()
}
return {
terminalState,
connected,
agentRunning,
sessionId,
containerRef,
renderer,
connect,
disconnect,
startAgent,
stopAgent,
checkStatus,
sendPrompt,
dispose
}
}

View File

@@ -214,8 +214,10 @@ export function useTerminalRenderer(options: TerminalRendererOptions): TerminalR
function dispose(): void { function dispose(): void {
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
terminal.value?.dispose() if (terminal.value) {
terminal.value = null try { terminal.value.dispose() } catch { /* addon not loaded */ }
terminal.value = null
}
fitAddon.value = null fitAddon.value = null
isReady.value = false isReady.value = false
} }

View File

@@ -57,5 +57,17 @@ export const endpoints = {
api: '/api' api: '/api'
} }
// Agent terminal helpers
export function agentTerminalUrl(agentId: string): string {
const base = endpoints.terminal
const sep = base.includes('?') ? '&' : '?'
return `${base}${sep}session=agent-${agentId}`
}
export function terminalApiUrl(path: string): string {
if (isSecure) return `https://${hostname}/ws/terminal${path}`
return `http://${hostname}:4103${path}`
}
// Debug logging // Debug logging
console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints) console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints)

View File

@@ -30,6 +30,7 @@ export interface UiConfig {
terminalBg: string terminalBg: string
terminalBorder: string terminalBorder: string
enabled: boolean enabled: boolean
command?: string
} }
export interface Agent { export interface Agent {

View File

@@ -1,6 +1,7 @@
import { jsonResponse, errorResponse } from '../utils/cors' import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config' import { PORT_TERMINAL } from '../config'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync } from 'fs'
import { setActiveSession, getIncrementalMessages } from '../services/transcript-engine'
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking' type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
@@ -118,6 +119,30 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
console.error('[claude-hook] Failed to forward status to terminal server:', e) console.error('[claude-hook] Failed to forward status to terminal server:', e)
} }
// 3. Incremental transcript reading for real-time chat
if (body.session_id && body.transcript_path) {
const agentName = agent || 'main'
setActiveSession(agentName, body.session_id, body.transcript_path as string)
const newMessages = getIncrementalMessages(body.session_id, body.transcript_path as string)
if (newMessages.length > 0) {
try {
await fetch(`http://localhost:${PORT_TERMINAL}/transcript-update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: body.session_id,
agent: agentName,
messages: newMessages,
hookEvent: body.hook_event_name
})
})
} catch (e) {
console.error('[claude-hook] Failed to forward transcript update:', e)
}
}
}
return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' }) return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' })
} catch (e) { } catch (e) {
return errorResponse('Invalid JSON body', 400) return errorResponse('Invalid JSON body', 400)

View File

@@ -20,7 +20,7 @@ import {
handleAgentsPlugins, handleAgentsMcpJson, handleAgentsPlugins, handleAgentsMcpJson,
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
} from './agents' } from './agents'
import { handleTranscript, handleTranscriptSessions } from './transcript' import { handleTranscript, handleTranscriptSessions, handleTranscriptActive, handleClaudeStats, handleClaudeUsage } from './transcript'
export async function handleRequest(req: Request): Promise<Response> { export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
@@ -278,7 +278,21 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleGitFile(url) return handleGitFile(url)
} }
// Claude usage limits (estimated)
if (path === '/api/claude-usage') {
return handleClaudeUsage()
}
// Claude stats (global)
if (path === '/api/claude-stats') {
return handleClaudeStats()
}
// Transcript // Transcript
if (path === '/api/transcript/active' && req.method === 'GET') {
return handleTranscriptActive(req, url)
}
if (path === '/api/transcript/sessions' && req.method === 'GET') { if (path === '/api/transcript/sessions' && req.method === 'GET') {
return handleTranscriptSessions() return handleTranscriptSessions()
} }

View File

@@ -1,12 +1,44 @@
import { jsonResponse, errorResponse } from '../utils/cors' import { jsonResponse, errorResponse } from '../utils/cors'
import { getTranscriptAnalysis, listSessions } from '../services/transcript-engine' import { getTranscriptAnalysis, listSessions, getActiveSession, resetSessionOffset, getClaudeStats, getClaudeUsage } from '../services/transcript-engine'
import type { TranscriptAnalysis } from '../services/transcript-engine' import type { TranscriptAnalysis } from '../services/transcript-engine'
export function handleClaudeUsage(): Response {
const usage = getClaudeUsage()
if (!usage) return errorResponse('Usage data not available', 404)
return jsonResponse(usage)
}
export function handleClaudeStats(): Response {
const stats = getClaudeStats()
if (!stats) return errorResponse('Stats not available', 404)
return jsonResponse(stats)
}
export function handleTranscriptSessions(): Response { export function handleTranscriptSessions(): Response {
const sessions = listSessions() const sessions = listSessions()
return jsonResponse(sessions) return jsonResponse(sessions)
} }
export function handleTranscriptActive(req: Request, url: URL): Response {
if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
const agent = url.searchParams.get('agent') || 'main'
const activeSession = getActiveSession(agent)
const sessionId = activeSession?.sessionId
const analysis = getTranscriptAnalysis(sessionId, activeSession?.transcriptPath)
if (!analysis) return errorResponse('No active session found', 404)
// Reset offset so future incrementals start from here
resetSessionOffset(analysis.sessionId)
return jsonResponse({
...analysis,
messages: analysis.messages.filter(m => !m.isMeta)
})
}
export function handleTranscript(req: Request, url: URL, sessionId: string): Response { export function handleTranscript(req: Request, url: URL, sessionId: string): Response {
if (req.method !== 'GET') return errorResponse('Method not allowed', 405) if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
@@ -63,7 +95,8 @@ function handleSection(analysis: TranscriptAnalysis, section: string): Response
version: analysis.version, version: analysis.version,
duration: analysis.duration, duration: analysis.duration,
startTime: analysis.startTime, startTime: analysis.startTime,
endTime: analysis.endTime endTime: analysis.endTime,
lastStopReason: analysis.lastStopReason
}) })
case 'files': case 'files':
return jsonResponse({ return jsonResponse({

View File

@@ -10,6 +10,23 @@ interface TerminalSession {
createdAt: Date createdAt: Date
} }
// Agent terminal state tracking
interface AgentTerminalState {
agentId: string
sessionId: string
command: string
startedAt: Date | null
isAgentRunning: boolean
}
export const agentSessions = new Map<string, AgentTerminalState>()
const AGENT_COMMANDS: Record<string, string> = {
'main': 'claude',
'ejecutor': 'ejecutor',
'nucleo000': 'nucleo000'
}
// Store active terminal sessions by ID (persistent across reconnections) // Store active terminal sessions by ID (persistent across reconnections)
const sessions = new Map<string, TerminalSession>() const sessions = new Map<string, TerminalSession>()
@@ -65,6 +82,16 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
} catch { /* ignore */ } } catch { /* ignore */ }
} }
sessions.delete(sessionId) sessions.delete(sessionId)
// Mark agent as not running if this is an agent session
if (sessionId.startsWith('agent-')) {
const agentId = sessionId.replace('agent-', '')
const state = agentSessions.get(agentId)
if (state) {
state.isAgentRunning = false
console.log(`[Terminal] Agent ${agentId} marked as stopped (exit code ${exitCode})`)
}
}
}) })
sessions.set(sessionId, session) sessions.set(sessionId, session)
@@ -74,6 +101,60 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
return session return session
} }
// Kill an existing session's PTY process
export function killSession(sessionId: string): boolean {
const session = sessions.get(sessionId)
if (!session) return false
console.log(`[Terminal] Killing session: ${sessionId} (PID: ${session.pty.pid})`)
// Notify clients before killing
for (const ws of session.clients) {
try {
ws.send(JSON.stringify({ type: 'session-restart', sessionId }))
} catch { /* ignore */ }
}
try {
session.pty.kill()
} catch (e) {
console.error(`[Terminal] Error killing PTY for ${sessionId}:`, e)
}
sessions.delete(sessionId)
return true
}
// Start an agent command in its dedicated session
export async function startAgentInSession(agentId: string, force = false): Promise<AgentTerminalState> {
const sessionId = `agent-${agentId}`
const command = AGENT_COMMANDS[agentId] || agentId
// If force restart, kill existing session first
if (force && sessions.has(sessionId)) {
killSession(sessionId)
await new Promise(r => setTimeout(r, 300))
}
const session = getOrCreateSession(sessionId)
// Write the agent command to the PTY
session.pty.write(command + '\r')
const state: AgentTerminalState = {
agentId,
sessionId,
command,
startedAt: new Date(),
isAgentRunning: true
}
agentSessions.set(agentId, state)
console.log(`[Terminal] Agent ${agentId} started in session ${sessionId} with command: ${command}`)
return state
}
export function startTerminalServer() { export function startTerminalServer() {
const server = Bun.serve({ const server = Bun.serve({
port: PORT_TERMINAL, port: PORT_TERMINAL,
@@ -146,6 +227,66 @@ export function startTerminalServer() {
} }
} }
// Agent sessions info
if (url.pathname === '/agent-sessions' && req.method === 'GET') {
const result: Record<string, any> = {}
for (const [id, state] of agentSessions) {
const session = sessions.get(state.sessionId)
result[id] = {
...state,
pid: session?.pty.pid ?? null,
bufferSize: session?.outputBuffer.length ?? 0,
clientCount: session?.clients.size ?? 0,
sessionExists: !!session
}
}
return Response.json(result, { headers: corsHeaders })
}
// Start agent in session
if (url.pathname === '/start-agent' && req.method === 'POST') {
try {
const body = await req.json() as { agentId: string; force?: boolean }
if (!body.agentId) {
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
}
const state = await startAgentInSession(body.agentId, body.force)
return Response.json({ success: true, state }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// Stop agent session
if (url.pathname === '/stop-agent' && req.method === 'POST') {
try {
const body = await req.json() as { agentId: string }
if (!body.agentId) {
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
}
const sessionId = `agent-${body.agentId}`
const killed = killSession(sessionId)
if (killed) {
const state = agentSessions.get(body.agentId)
if (state) state.isAgentRunning = false
}
return Response.json({ success: true, killed }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
}
}
// Transcript update broadcast endpoint
if (url.pathname === '/transcript-update' && req.method === 'POST') {
try {
const body = await req.json()
broadcastTranscriptUpdate(body as Record<string, unknown>)
return Response.json({ success: true }, { headers: corsHeaders })
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
}
}
// Check if this is a WebSocket upgrade request // Check if this is a WebSocket upgrade request
const upgradeHeader = req.headers.get('upgrade') const upgradeHeader = req.headers.get('upgrade')
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
@@ -269,11 +410,22 @@ type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' |
// Broadcast Claude status to ALL clients across ALL sessions // Broadcast Claude status to ALL clients across ALL sessions
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) { export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
const agentName = agent || 'main'
// Track agent running state from sessionStart
if (status === 'sessionStart') {
const state = agentSessions.get(agentName)
if (state) {
state.isAgentRunning = true
console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`)
}
}
const message = JSON.stringify({ const message = JSON.stringify({
type: 'claude-status', type: 'claude-status',
status, status,
tool, tool,
agent: agent || 'main', agent: agentName,
timestamp: Date.now() timestamp: Date.now()
}) })
@@ -337,3 +489,24 @@ export function broadcastPermissionRequest(data: Record<string, unknown>) {
console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`) console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`)
} }
// Broadcast transcript updates to ALL clients
export function broadcastTranscriptUpdate(data: Record<string, unknown>) {
const message = JSON.stringify({
type: 'transcript-update',
...data,
timestamp: Date.now()
})
let clientCount = 0
for (const [, session] of sessions) {
for (const ws of session.clients) {
try {
ws.send(message)
clientCount++
} catch { /* skip */ }
}
}
console.log(`[Terminal] Transcript update: ${data.hookEvent || 'fetch'} (${(data.messages as any[])?.length || 0} msgs) → ${clientCount} clients`)
}

View File

@@ -40,6 +40,7 @@ export interface TranscriptAnalysis {
thinkingBlocks: number thinkingBlocks: number
errors: number errors: number
} }
lastStopReason: string
} }
export interface TranscriptMessage { export interface TranscriptMessage {
@@ -83,6 +84,49 @@ export interface SessionInfo {
model: string model: string
} }
export interface ClaudeStats {
today: {
date: string
messageCount: number
sessionCount: number
toolCallCount: number
tokensByModel: Record<string, number>
} | null
modelUsage: Record<string, {
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
costUSD: number
}>
totalSessions: number
totalMessages: number
firstSessionDate: string
}
export interface ClaudeUsage {
subscription: { type: string; tier: string; label: string; multiplier: number }
today: { messages: number; outputTokens: number; sessions: number }
daily: { used: number; limit: number; percent: number }
weekly: { used: number; limit: number; percent: number }
status: 'normal' | 'elevated' | 'extended' | 'limit_approaching'
}
const TIER_LIMITS: Record<string, { windowMessages: number; dailyEstimate: number; weeklyEstimate: number }> = {
pro: { windowMessages: 45, dailyEstimate: 500, weeklyEstimate: 3500 },
max_5x: { windowMessages: 225, dailyEstimate: 2500, weeklyEstimate: 17500 },
max_20x: { windowMessages: 900, dailyEstimate: 10000, weeklyEstimate: 70000 },
}
function parseTier(rateLimitTier: string): { key: string; label: string; multiplier: number } {
const match = rateLimitTier.match(/max_(\d+)x/)
if (match) {
const n = parseInt(match[1])
return { key: `max_${n}x`, label: `Max ${n}x`, multiplier: n }
}
return { key: 'pro', label: 'Pro', multiplier: 1 }
}
// ── Module-level cache ── // ── Module-level cache ──
const cache = new Map<string, { const cache = new Map<string, {
@@ -90,6 +134,16 @@ const cache = new Map<string, {
lastModified: number lastModified: number
}>() }>()
// ── Active session tracking (for real-time chat) ──
const activeAgentSessions = new Map<string, {
sessionId: string
transcriptPath: string
}>()
// Byte offset per session (how far we've read)
const readOffsets = new Map<string, number>()
// ── Project hash ── // ── Project hash ──
function getProjectHash(): string { function getProjectHash(): string {
@@ -214,6 +268,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
toolNames: string[] toolNames: string[]
hasThinking: boolean hasThinking: boolean
usage: any usage: any
stopReason: string
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[] pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
}>() }>()
@@ -293,6 +348,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
toolNames: [], toolNames: [],
hasThinking: false, hasThinking: false,
usage: null, usage: null,
stopReason: '',
pendingToolCalls: [] pendingToolCalls: []
} }
assistantChunks.set(msgId, chunk) assistantChunks.set(msgId, chunk)
@@ -300,6 +356,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
// Take latest usage (streaming chunks repeat usage, last is most accurate) // Take latest usage (streaming chunks repeat usage, last is most accurate)
if (msg.usage) chunk.usage = msg.usage if (msg.usage) chunk.usage = msg.usage
if (msg.stop_reason) chunk.stopReason = msg.stop_reason
// Process content blocks (each JSONL line typically has one block) // Process content blocks (each JSONL line typically has one block)
if (Array.isArray(msg.content)) { if (Array.isArray(msg.content)) {
@@ -362,7 +419,9 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
// ── Second pass: assemble assistant messages and finalize tool calls ── // ── Second pass: assemble assistant messages and finalize tool calls ──
let turnIndex = 0 let turnIndex = 0
let lastStopReason = ''
for (const [, chunk] of assistantChunks) { for (const [, chunk] of assistantChunks) {
if (chunk.stopReason) lastStopReason = chunk.stopReason
const text = chunk.textParts.join('\n').trim() const text = chunk.textParts.join('\n').trim()
if (text || chunk.toolNames.length > 0) { if (text || chunk.toolNames.length > 0) {
@@ -477,14 +536,17 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
toolCallCount: toolCalls.length, toolCallCount: toolCalls.length,
thinkingBlocks, thinkingBlocks,
errors errors
} },
lastStopReason
} }
} }
// ── Exported API ── // ── Exported API ──
export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null { export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null {
const filePath = resolveTranscriptPath(sessionId) const filePath = transcriptPath && existsSync(transcriptPath)
? transcriptPath
: resolveTranscriptPath(sessionId)
if (!filePath) return null if (!filePath) return null
const sid = sessionIdFromPath(filePath) const sid = sessionIdFromPath(filePath)
@@ -511,6 +573,283 @@ export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis |
} }
} }
// ── Path normalization (Windows compat) ──
function normalizeTranscriptPath(tp: string): string {
tp = tp.replace(/\\/g, '/')
if (/^\/[a-zA-Z]\//.test(tp)) {
tp = tp[1].toUpperCase() + ':' + tp.slice(2)
}
return tp
}
// ── Active session API (for real-time chat) ──
export function setActiveSession(agent: string, sessionId: string, transcriptPath: string): void {
const normalizedPath = normalizeTranscriptPath(transcriptPath)
activeAgentSessions.set(agent, { sessionId, transcriptPath: normalizedPath })
// Initialize offset to current file size (don't replay old messages as "new")
if (!readOffsets.has(sessionId)) {
try {
const stat = statSync(normalizedPath)
readOffsets.set(sessionId, stat.size)
} catch {
readOffsets.set(sessionId, 0)
}
}
}
export function getActiveSession(agent: string): { sessionId: string; transcriptPath: string } | null {
return activeAgentSessions.get(agent) || null
}
export function resetSessionOffset(sessionId: string): void {
readOffsets.set(sessionId, 0)
}
export function getIncrementalMessages(sessionId: string, transcriptPath?: string): TranscriptMessage[] {
// Resolve path
let filePath = transcriptPath ? normalizeTranscriptPath(transcriptPath) : null
if (!filePath) {
const session = [...activeAgentSessions.values()].find(s => s.sessionId === sessionId)
filePath = session?.transcriptPath || null
}
if (!filePath) {
filePath = resolveTranscriptPath(sessionId)
}
if (!filePath || !existsSync(filePath)) return []
try {
const stat = statSync(filePath)
const fileSize = stat.size
const offset = readOffsets.get(sessionId) || 0
if (offset >= fileSize) return []
// Read only new bytes
const buffer = readFileSync(filePath)
const newContent = buffer.slice(offset, fileSize).toString('utf8')
// Update offset
readOffsets.set(sessionId, fileSize)
// Parse new lines
const rawLines = newContent.split('\n').filter(l => l.trim())
const messages: TranscriptMessage[] = []
// Track assistant chunks by message.id for consolidation
const assistantChunks = new Map<string, {
uuid: string
timestamp: string
textParts: string[]
toolNames: string[]
hasThinking: boolean
}>()
for (const line of rawLines) {
try {
const obj = JSON.parse(line)
if (obj.type === 'user') {
const msg = obj.message
if (!msg) continue
const isMeta = !!obj.isMeta
const text = extractText(msg.content)
const hasToolResult = Array.isArray(msg.content) &&
msg.content.some((c: any) => c.type === 'tool_result')
if (text && !hasToolResult && !isMeta) {
messages.push({
uuid: obj.uuid || crypto.randomUUID(),
role: 'user',
content: text,
timestamp: obj.timestamp || new Date().toISOString(),
isMeta: false,
hasThinking: false
})
}
} else if (obj.type === 'assistant') {
const msg = obj.message
if (!msg || msg.role !== 'assistant') continue
const msgId = msg.id || obj.uuid || 'unknown'
let chunk = assistantChunks.get(msgId)
if (!chunk) {
chunk = {
uuid: obj.uuid || crypto.randomUUID(),
timestamp: obj.timestamp || new Date().toISOString(),
textParts: [],
toolNames: [],
hasThinking: false
}
assistantChunks.set(msgId, chunk)
}
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'text' && block.text?.trim()) {
chunk.textParts.push(block.text)
} else if (block.type === 'thinking') {
chunk.hasThinking = true
} else if (block.type === 'tool_use') {
chunk.toolNames.push(block.name)
}
}
}
}
// Ignore progress, file-history-snapshot, summary
} catch {
// Skip unparseable lines
}
}
// Assemble assistant messages from consolidated chunks
for (const [, chunk] of assistantChunks) {
const text = chunk.textParts.join('\n').trim()
if (text || chunk.toolNames.length > 0) {
messages.push({
uuid: chunk.uuid,
role: 'assistant',
content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
timestamp: chunk.timestamp,
isMeta: false,
toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
hasThinking: chunk.hasThinking
})
}
}
// Sort chronologically
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return messages
} catch (e) {
console.error('[transcript-engine] Incremental read error:', e)
return []
}
}
export function getClaudeStats(): ClaudeStats | null {
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
if (!existsSync(statsPath)) return null
try {
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
const todayStr = new Date().toISOString().slice(0, 10)
// Find today's daily activity
const todayActivity = raw.dailyActivity?.find((d: any) => d.date === todayStr) || null
const todayTokens = raw.dailyModelTokens?.find((d: any) => d.date === todayStr) || null
const today = todayActivity ? {
date: todayStr,
messageCount: todayActivity.messageCount || 0,
sessionCount: todayActivity.sessionCount || 0,
toolCallCount: todayActivity.toolCallCount || 0,
tokensByModel: todayTokens?.tokensByModel || {}
} : null
// Model usage
const modelUsage: ClaudeStats['modelUsage'] = {}
if (raw.modelUsage) {
for (const [model, usage] of Object.entries(raw.modelUsage as Record<string, any>)) {
modelUsage[model] = {
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheReadInputTokens: usage.cacheReadInputTokens || 0,
cacheCreationInputTokens: usage.cacheCreationInputTokens || 0,
costUSD: usage.costUSD || 0
}
}
}
return {
today,
modelUsage,
totalSessions: raw.totalSessions || 0,
totalMessages: raw.totalMessages || 0,
firstSessionDate: raw.firstSessionDate || ''
}
} catch (e) {
console.error('[transcript-engine] Error reading stats-cache.json:', e)
return null
}
}
export function getClaudeUsage(): ClaudeUsage | null {
const credentialsPath = join(homedir(), '.claude', '.credentials.json')
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
if (!existsSync(credentialsPath)) return null
try {
// 1. Read credentials (never expose tokens)
const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'))
const oauth = creds.claudeAiOauth || {}
const subType = oauth.subscriptionType || 'pro'
const rawTier = oauth.rateLimitTier || ''
const tier = parseTier(rawTier)
const limits = TIER_LIMITS[tier.key] || TIER_LIMITS.pro
// 2. Read stats-cache for daily + weekly data
let todayMessages = 0
let todayOutputTokens = 0
let todaySessions = 0
let weeklyMessages = 0
if (existsSync(statsPath)) {
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
const todayStr = new Date().toISOString().slice(0, 10)
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
for (const day of raw.dailyActivity || []) {
if (day.date === todayStr) {
todayMessages = day.messageCount || 0
todaySessions = day.sessionCount || 0
}
if (day.date >= sevenDaysAgo) {
weeklyMessages += day.messageCount || 0
}
}
// Output tokens for today
const todayTokens = (raw.dailyModelTokens || []).find((d: any) => d.date === todayStr)
if (todayTokens?.tokensByModel) {
todayOutputTokens = Object.values(todayTokens.tokensByModel as Record<string, number>)
.reduce((sum: number, v: number) => sum + v, 0)
}
}
// 3. Calculate percentages
const dailyPercent = limits.dailyEstimate > 0
? Math.round(todayMessages / limits.dailyEstimate * 100)
: 0
const weeklyPercent = limits.weeklyEstimate > 0
? Math.round(weeklyMessages / limits.weeklyEstimate * 100)
: 0
// 4. Determine status based on highest usage (daily or weekly)
const maxPercent = Math.max(dailyPercent, weeklyPercent)
let status: ClaudeUsage['status'] = 'normal'
if (maxPercent >= 100) status = 'extended'
else if (maxPercent >= 80) status = 'limit_approaching'
else if (maxPercent >= 50) status = 'elevated'
return {
subscription: { type: subType, tier: tier.key, label: tier.label, multiplier: tier.multiplier },
today: { messages: todayMessages, outputTokens: todayOutputTokens, sessions: todaySessions },
daily: { used: todayMessages, limit: limits.dailyEstimate, percent: dailyPercent },
weekly: { used: weeklyMessages, limit: limits.weeklyEstimate, percent: weeklyPercent },
status
}
} catch (e) {
console.error('[transcript-engine] Error reading claude usage:', e)
return null
}
}
export function listSessions(): SessionInfo[] { export function listSessions(): SessionInfo[] {
const projectDir = getProjectDir() const projectDir = getProjectDir()
if (!existsSync(projectDir)) return [] if (!existsSync(projectDir)) return []