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:
@@ -5,6 +5,6 @@
|
||||
"repo": "anthropics/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"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -38,7 +38,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -50,7 +50,18 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -62,7 +73,19 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -73,8 +96,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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 {}\"",
|
||||
"timeout": 5000
|
||||
"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": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
"terminalBg": "#0f0a1a",
|
||||
"terminalBorder": "#6366f1",
|
||||
"enabled": false
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import FloatingResponse from './components/FloatingResponse.vue'
|
||||
import FloatingVoice from './components/FloatingVoice.vue'
|
||||
import AgentBar from './components/AgentBar.vue'
|
||||
import HookNotifications from './components/HookNotifications.vue'
|
||||
import NotificationLog from './components/NotificationLog.vue'
|
||||
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||
import { initWebMCP, getWebMCP } from './services/webmcp'
|
||||
import { initTorch, destroyTorch } from './services/torch'
|
||||
@@ -67,6 +68,7 @@ function clearDebugLogs() {
|
||||
}
|
||||
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | 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 canvasStore = useCanvasStore()
|
||||
const projectCanvasStore = useProjectCanvasStore()
|
||||
@@ -330,6 +332,8 @@ onMounted(async () => {
|
||||
// Setup response controls for MCP tools
|
||||
setResponseControls({
|
||||
addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
|
||||
// Also log to notification log
|
||||
notifLogRef.value?.addResponseEntry(message, type || 'info')
|
||||
if (responseRef.value) {
|
||||
return responseRef.value.addMessage(message, type)
|
||||
}
|
||||
@@ -544,6 +548,9 @@ watch(() => route.name, (newPage) => {
|
||||
<!-- Hook Notifications (toasts from Claude Code hooks) -->
|
||||
<HookNotifications />
|
||||
|
||||
<!-- Notification Log (temporary - collects all notifications, persists to localStorage) -->
|
||||
<NotificationLog ref="notifLogRef" />
|
||||
|
||||
<!-- Floating Voice Input -->
|
||||
<FloatingVoice ref="voiceRef" v-model="showVoice" />
|
||||
|
||||
|
||||
@@ -345,6 +345,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<PromptBar
|
||||
v-if="activeAgent"
|
||||
:key="activeAgent.id"
|
||||
ref="promptBarRef"
|
||||
:agent="activeAgent"
|
||||
:anchor-rect="activeAnchorRect"
|
||||
|
||||
464
frontend/src/components/NotificationLog.vue
Normal file
464
frontend/src/components/NotificationLog.vue
Normal 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">×</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>
|
||||
436
frontend/src/components/agent/AgentTerminal.vue
Normal file
436
frontend/src/components/agent/AgentTerminal.vue
Normal 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>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
401
frontend/src/composables/useAgentTerminal.ts
Normal file
401
frontend/src/composables/useAgentTerminal.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -214,8 +214,10 @@ export function useTerminalRenderer(options: TerminalRendererOptions): TerminalR
|
||||
function dispose(): void {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
terminal.value?.dispose()
|
||||
terminal.value = null
|
||||
if (terminal.value) {
|
||||
try { terminal.value.dispose() } catch { /* addon not loaded */ }
|
||||
terminal.value = null
|
||||
}
|
||||
fitAddon.value = null
|
||||
isReady.value = false
|
||||
}
|
||||
|
||||
@@ -57,5 +57,17 @@ export const endpoints = {
|
||||
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
|
||||
console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints)
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface UiConfig {
|
||||
terminalBg: string
|
||||
terminalBorder: string
|
||||
enabled: boolean
|
||||
command?: string
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
import { PORT_TERMINAL } from '../config'
|
||||
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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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' })
|
||||
} catch (e) {
|
||||
return errorResponse('Invalid JSON body', 400)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
handleAgentsPlugins, handleAgentsMcpJson,
|
||||
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
|
||||
} from './agents'
|
||||
import { handleTranscript, handleTranscriptSessions } from './transcript'
|
||||
import { handleTranscript, handleTranscriptSessions, handleTranscriptActive, handleClaudeStats, handleClaudeUsage } from './transcript'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -278,7 +278,21 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
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
|
||||
if (path === '/api/transcript/active' && req.method === 'GET') {
|
||||
return handleTranscriptActive(req, url)
|
||||
}
|
||||
|
||||
if (path === '/api/transcript/sessions' && req.method === 'GET') {
|
||||
return handleTranscriptSessions()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
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'
|
||||
|
||||
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 {
|
||||
const sessions = listSessions()
|
||||
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 {
|
||||
if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
|
||||
|
||||
@@ -63,7 +95,8 @@ function handleSection(analysis: TranscriptAnalysis, section: string): Response
|
||||
version: analysis.version,
|
||||
duration: analysis.duration,
|
||||
startTime: analysis.startTime,
|
||||
endTime: analysis.endTime
|
||||
endTime: analysis.endTime,
|
||||
lastStopReason: analysis.lastStopReason
|
||||
})
|
||||
case 'files':
|
||||
return jsonResponse({
|
||||
|
||||
@@ -10,6 +10,23 @@ interface TerminalSession {
|
||||
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)
|
||||
const sessions = new Map<string, TerminalSession>()
|
||||
|
||||
@@ -65,6 +82,16 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
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)
|
||||
@@ -74,6 +101,60 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
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() {
|
||||
const server = Bun.serve({
|
||||
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
|
||||
const upgradeHeader = req.headers.get('upgrade')
|
||||
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
|
||||
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({
|
||||
type: 'claude-status',
|
||||
status,
|
||||
tool,
|
||||
agent: agent || 'main',
|
||||
agent: agentName,
|
||||
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`)
|
||||
}
|
||||
|
||||
// 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`)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface TranscriptAnalysis {
|
||||
thinkingBlocks: number
|
||||
errors: number
|
||||
}
|
||||
lastStopReason: string
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
@@ -83,6 +84,49 @@ export interface SessionInfo {
|
||||
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 ──
|
||||
|
||||
const cache = new Map<string, {
|
||||
@@ -90,6 +134,16 @@ const cache = new Map<string, {
|
||||
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 ──
|
||||
|
||||
function getProjectHash(): string {
|
||||
@@ -214,6 +268,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
||||
toolNames: string[]
|
||||
hasThinking: boolean
|
||||
usage: any
|
||||
stopReason: string
|
||||
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
|
||||
}>()
|
||||
|
||||
@@ -293,6 +348,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
||||
toolNames: [],
|
||||
hasThinking: false,
|
||||
usage: null,
|
||||
stopReason: '',
|
||||
pendingToolCalls: []
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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 ──
|
||||
let turnIndex = 0
|
||||
let lastStopReason = ''
|
||||
for (const [, chunk] of assistantChunks) {
|
||||
if (chunk.stopReason) lastStopReason = chunk.stopReason
|
||||
const text = chunk.textParts.join('\n').trim()
|
||||
|
||||
if (text || chunk.toolNames.length > 0) {
|
||||
@@ -477,14 +536,17 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
||||
toolCallCount: toolCalls.length,
|
||||
thinkingBlocks,
|
||||
errors
|
||||
}
|
||||
},
|
||||
lastStopReason
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exported API ──
|
||||
|
||||
export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null {
|
||||
const filePath = resolveTranscriptPath(sessionId)
|
||||
export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null {
|
||||
const filePath = transcriptPath && existsSync(transcriptPath)
|
||||
? transcriptPath
|
||||
: resolveTranscriptPath(sessionId)
|
||||
if (!filePath) return null
|
||||
|
||||
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[] {
|
||||
const projectDir = getProjectDir()
|
||||
if (!existsSync(projectDir)) return []
|
||||
|
||||
Reference in New Issue
Block a user