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"
|
"repo": "anthropics/claude-plugins-official"
|
||||||
},
|
},
|
||||||
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
||||||
"lastUpdated": "2026-02-15T19:50:55.853Z"
|
"lastUpdated": "2026-02-16T06:32:07.237Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -50,7 +50,18 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -62,7 +73,19 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PermissionRequest": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -73,8 +96,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=ejecutor' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"",
|
||||||
"timeout": 5000
|
"timeout": 10000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
"terminalBg": "#0f0a1a",
|
"terminalBg": "#0f0a1a",
|
||||||
"terminalBorder": "#6366f1",
|
"terminalBorder": "#6366f1",
|
||||||
"enabled": false
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FloatingResponse from './components/FloatingResponse.vue'
|
|||||||
import FloatingVoice from './components/FloatingVoice.vue'
|
import FloatingVoice from './components/FloatingVoice.vue'
|
||||||
import AgentBar from './components/AgentBar.vue'
|
import AgentBar from './components/AgentBar.vue'
|
||||||
import HookNotifications from './components/HookNotifications.vue'
|
import HookNotifications from './components/HookNotifications.vue'
|
||||||
|
import NotificationLog from './components/NotificationLog.vue'
|
||||||
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||||
import { initWebMCP, getWebMCP } from './services/webmcp'
|
import { initWebMCP, getWebMCP } from './services/webmcp'
|
||||||
import { initTorch, destroyTorch } from './services/torch'
|
import { initTorch, destroyTorch } from './services/torch'
|
||||||
@@ -67,6 +68,7 @@ function clearDebugLogs() {
|
|||||||
}
|
}
|
||||||
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
|
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
|
||||||
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
|
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
|
||||||
|
const notifLogRef = ref<InstanceType<typeof NotificationLog> | null>(null)
|
||||||
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
|
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const projectCanvasStore = useProjectCanvasStore()
|
const projectCanvasStore = useProjectCanvasStore()
|
||||||
@@ -330,6 +332,8 @@ onMounted(async () => {
|
|||||||
// Setup response controls for MCP tools
|
// Setup response controls for MCP tools
|
||||||
setResponseControls({
|
setResponseControls({
|
||||||
addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
|
addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
|
||||||
|
// Also log to notification log
|
||||||
|
notifLogRef.value?.addResponseEntry(message, type || 'info')
|
||||||
if (responseRef.value) {
|
if (responseRef.value) {
|
||||||
return responseRef.value.addMessage(message, type)
|
return responseRef.value.addMessage(message, type)
|
||||||
}
|
}
|
||||||
@@ -544,6 +548,9 @@ watch(() => route.name, (newPage) => {
|
|||||||
<!-- Hook Notifications (toasts from Claude Code hooks) -->
|
<!-- Hook Notifications (toasts from Claude Code hooks) -->
|
||||||
<HookNotifications />
|
<HookNotifications />
|
||||||
|
|
||||||
|
<!-- Notification Log (temporary - collects all notifications, persists to localStorage) -->
|
||||||
|
<NotificationLog ref="notifLogRef" />
|
||||||
|
|
||||||
<!-- Floating Voice Input -->
|
<!-- Floating Voice Input -->
|
||||||
<FloatingVoice ref="voiceRef" v-model="showVoice" />
|
<FloatingVoice ref="voiceRef" v-model="showVoice" />
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<PromptBar
|
<PromptBar
|
||||||
v-if="activeAgent"
|
v-if="activeAgent"
|
||||||
|
:key="activeAgent.id"
|
||||||
ref="promptBarRef"
|
ref="promptBarRef"
|
||||||
:agent="activeAgent"
|
:agent="activeAgent"
|
||||||
:anchor-rect="activeAnchorRect"
|
:anchor-rect="activeAnchorRect"
|
||||||
|
|||||||
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
|
recording?: boolean
|
||||||
historyActive?: boolean
|
historyActive?: boolean
|
||||||
settingsActive?: boolean
|
settingsActive?: boolean
|
||||||
|
terminalActive?: boolean
|
||||||
|
disabled?: boolean
|
||||||
autofocus?: boolean
|
autofocus?: boolean
|
||||||
|
statusDot?: 'green' | 'yellow' | 'red' | 'gray' | ''
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -14,6 +17,7 @@ const emit = defineEmits<{
|
|||||||
mic: []
|
mic: []
|
||||||
'toggle-history': []
|
'toggle-history': []
|
||||||
'toggle-settings': []
|
'toggle-settings': []
|
||||||
|
'toggle-terminal': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
@@ -42,12 +46,14 @@ defineExpose({ focus })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-input-row">
|
<div class="chat-input-row">
|
||||||
|
<i v-if="statusDot" class="ci-status-dot" :class="statusDot"></i>
|
||||||
<input
|
<input
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="chat-input"
|
class="chat-input"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="placeholder || 'Escribe un mensaje...'"
|
:placeholder="placeholder || 'Escribe un mensaje...'"
|
||||||
|
:disabled="disabled"
|
||||||
@keydown.enter="handleSubmit"
|
@keydown.enter="handleSubmit"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -95,6 +101,17 @@ defineExpose({ focus })
|
|||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="ci-btn ci-terminal"
|
||||||
|
:class="{ active: terminalActive }"
|
||||||
|
title="Terminal"
|
||||||
|
@click="emit('toggle-terminal')"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="4 17 10 11 4 5"/>
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -162,11 +179,39 @@ defineExpose({ focus })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ci-history.active,
|
.ci-history.active,
|
||||||
.ci-settings.active {
|
.ci-settings.active,
|
||||||
|
.ci-terminal.active {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ci-terminal:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border-color: rgba(16, 185, 129, 0.25);
|
||||||
|
color: rgba(16, 185, 129, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ci-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ci-status-dot.green { background: #22c55e; box-shadow: 0 0 4px #22c55e; }
|
||||||
|
.ci-status-dot.yellow { background: #eab308; box-shadow: 0 0 4px #eab308; animation: ci-pulse 1s infinite; }
|
||||||
|
.ci-status-dot.red { background: #ef4444; box-shadow: 0 0 4px #ef4444; }
|
||||||
|
.ci-status-dot.gray { background: #6b7280; }
|
||||||
|
|
||||||
|
.chat-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ci-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes mic-pulse {
|
@keyframes mic-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
|
||||||
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount }
|
|||||||
import type { Agent } from '../../types/agent'
|
import type { Agent } from '../../types/agent'
|
||||||
import { endpoints } from '../../config/endpoints'
|
import { endpoints } from '../../config/endpoints'
|
||||||
import { useVoiceCapture } from '../../composables/useVoiceCapture'
|
import { useVoiceCapture } from '../../composables/useVoiceCapture'
|
||||||
|
import { useAgentTerminal } from '../../composables/useAgentTerminal'
|
||||||
import { useCanvasStore } from '../../stores/canvas'
|
import { useCanvasStore } from '../../stores/canvas'
|
||||||
import ChatInput from './ChatInput.vue'
|
import ChatInput from './ChatInput.vue'
|
||||||
import TranscriptCard from './TranscriptCard.vue'
|
import TranscriptCard from './TranscriptCard.vue'
|
||||||
import InputSettings from './InputSettings.vue'
|
import InputSettings from './InputSettings.vue'
|
||||||
import ConversationHistory from './ConversationHistory.vue'
|
import ConversationHistory from './ConversationHistory.vue'
|
||||||
|
import AgentTerminal from './AgentTerminal.vue'
|
||||||
|
|
||||||
interface ClaudeUsage {
|
interface ClaudeUsage {
|
||||||
subscription: { type: string; tier: string; label: string; multiplier: number }
|
subscription: { type: string; tier: string; label: string; multiplier: number }
|
||||||
@@ -23,6 +25,7 @@ interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
status: 'sent' | 'thinking' | 'done'
|
status: 'sent' | 'thinking' | 'done'
|
||||||
uuid?: string
|
uuid?: string
|
||||||
|
toolCalls?: string[]
|
||||||
intervention?: {
|
intervention?: {
|
||||||
type: 'permission' | 'question' | 'plan'
|
type: 'permission' | 'question' | 'plan'
|
||||||
requestId?: string
|
requestId?: string
|
||||||
@@ -60,6 +63,32 @@ const voice = useVoiceCapture({
|
|||||||
onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration)
|
onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Agent terminal composable
|
||||||
|
const agentTerminal = useAgentTerminal(props.agent.id)
|
||||||
|
const showTerminal = ref(false)
|
||||||
|
|
||||||
|
const statusDot = computed<'green' | 'yellow' | 'red' | 'gray' | ''>(() => {
|
||||||
|
switch (agentTerminal.terminalState.value) {
|
||||||
|
case 'ready': return 'green'
|
||||||
|
case 'connecting':
|
||||||
|
case 'agent-starting': return 'yellow'
|
||||||
|
case 'crashed': return 'red'
|
||||||
|
case 'off': return 'gray'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputPlaceholder = computed(() => {
|
||||||
|
const label = props.agent.uiConfig?.label || props.agent.name
|
||||||
|
switch (agentTerminal.terminalState.value) {
|
||||||
|
case 'off': return `Agent offline - type to start ${label}`
|
||||||
|
case 'crashed': return `Agent crashed - type to restart ${label}`
|
||||||
|
case 'connecting':
|
||||||
|
case 'agent-starting': return `Starting ${label}...`
|
||||||
|
default: return `Mensaje a ${label}...`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const contentEl = ref<HTMLDivElement | null>(null)
|
const contentEl = ref<HTMLDivElement | null>(null)
|
||||||
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
|
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
|
||||||
const isRecording = ref(false)
|
const isRecording = ref(false)
|
||||||
@@ -96,7 +125,7 @@ const panelStyle = computed(() => {
|
|||||||
const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2
|
const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2
|
||||||
const bottomOffset = window.innerHeight - props.anchorRect.top + 12
|
const bottomOffset = window.innerHeight - props.anchorRect.top + 12
|
||||||
|
|
||||||
const panelWidth = 360
|
const panelWidth = 420
|
||||||
let left = bubbleCenterX - panelWidth / 2
|
let left = bubbleCenterX - panelWidth / 2
|
||||||
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12))
|
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12))
|
||||||
|
|
||||||
@@ -112,6 +141,95 @@ const hasContent = computed(() =>
|
|||||||
messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value
|
messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Tool chip registry ──
|
||||||
|
|
||||||
|
interface ToolMeta { icon: string; color: string; label?: string }
|
||||||
|
|
||||||
|
const TOOL_CATEGORIES: Record<string, ToolMeta> = {
|
||||||
|
// File ops — cyan
|
||||||
|
Read: { icon: '◉', color: '#22d3ee' },
|
||||||
|
Edit: { icon: '✎', color: '#22d3ee' },
|
||||||
|
Write: { icon: '✦', color: '#22d3ee' },
|
||||||
|
Glob: { icon: '⊞', color: '#22d3ee' },
|
||||||
|
Grep: { icon: '⊘', color: '#22d3ee' },
|
||||||
|
// Terminal — green
|
||||||
|
Bash: { icon: '▸', color: '#34d399' },
|
||||||
|
KillShell: { icon: '✕', color: '#34d399', label: 'Kill' },
|
||||||
|
// Tasks — amber
|
||||||
|
Task: { icon: '◈', color: '#fbbf24' },
|
||||||
|
TaskCreate: { icon: '+', color: '#fbbf24', label: 'Task+' },
|
||||||
|
TaskUpdate: { icon: '↻', color: '#fbbf24', label: 'Task↻' },
|
||||||
|
TaskList: { icon: '≡', color: '#fbbf24', label: 'Tasks' },
|
||||||
|
TaskOutput: { icon: '◎', color: '#fbbf24', label: 'TaskOut' },
|
||||||
|
TodoWrite: { icon: '☑', color: '#fbbf24', label: 'Todo' },
|
||||||
|
// Web — purple
|
||||||
|
WebSearch: { icon: '⌕', color: '#a78bfa' },
|
||||||
|
WebFetch: { icon: '↓', color: '#a78bfa' },
|
||||||
|
// Interaction — rose
|
||||||
|
AskUserQuestion: { icon: '?', color: '#fb7185', label: 'Ask' },
|
||||||
|
EnterPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan↵' },
|
||||||
|
ExitPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan✓' },
|
||||||
|
Skill: { icon: '⚡', color: '#fb7185' },
|
||||||
|
// Resources — slate
|
||||||
|
ListMcpResourcesTool: { icon: '☰', color: '#94a3b8', label: 'Resources' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolMeta(name: string): ToolMeta {
|
||||||
|
if (TOOL_CATEGORIES[name]) return TOOL_CATEGORIES[name]
|
||||||
|
if (name.startsWith('mcp__')) {
|
||||||
|
const short = name.split('-').pop() || name
|
||||||
|
return { icon: '◆', color: '#818cf8', label: short }
|
||||||
|
}
|
||||||
|
return { icon: '•', color: '#94a3b8', label: name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display items with tool grouping ──
|
||||||
|
|
||||||
|
interface DisplayItem {
|
||||||
|
type: 'message' | 'tool-group'
|
||||||
|
msg?: ChatMessage
|
||||||
|
tools?: { name: string; count: number; meta: ToolMeta }[]
|
||||||
|
ids?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolGroup(tools: string[], ids: number[]): DisplayItem {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const t of tools) counts.set(t, (counts.get(t) || 0) + 1)
|
||||||
|
return {
|
||||||
|
type: 'tool-group',
|
||||||
|
tools: [...counts.entries()].map(([name, count]) => ({
|
||||||
|
name, count, meta: getToolMeta(name)
|
||||||
|
})),
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMessages = computed<DisplayItem[]>(() => {
|
||||||
|
const items: DisplayItem[] = []
|
||||||
|
let pendingTools: string[] = []
|
||||||
|
let pendingIds: number[] = []
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const isToolOnly = msg.toolCalls?.length && msg.content.startsWith('[Tool calls:')
|
||||||
|
|
||||||
|
if (isToolOnly) {
|
||||||
|
pendingTools.push(...msg.toolCalls!)
|
||||||
|
pendingIds.push(msg.id)
|
||||||
|
} else {
|
||||||
|
if (pendingTools.length) {
|
||||||
|
items.push(buildToolGroup(pendingTools, pendingIds))
|
||||||
|
pendingTools = []
|
||||||
|
pendingIds = []
|
||||||
|
}
|
||||||
|
items.push({ type: 'message', msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendingTools.length) {
|
||||||
|
items.push(buildToolGroup(pendingTools, pendingIds))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
// ── Formatting helpers ──
|
// ── Formatting helpers ──
|
||||||
|
|
||||||
function shortModel(model: string): string {
|
function shortModel(model: string): string {
|
||||||
@@ -241,7 +359,8 @@ function appendTranscriptMessages(newMsgs: any[]) {
|
|||||||
role: msg.role === 'user' ? 'user' : 'agent',
|
role: msg.role === 'user' ? 'user' : 'agent',
|
||||||
content: msg.content || '',
|
content: msg.content || '',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
uuid: msg.uuid
|
uuid: msg.uuid,
|
||||||
|
toolCalls: msg.toolCalls || undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +502,8 @@ async function loadHistory(sessionId?: string) {
|
|||||||
role: msg.role === 'user' ? 'user' : 'agent',
|
role: msg.role === 'user' ? 'user' : 'agent',
|
||||||
content: msg.content || '',
|
content: msg.content || '',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
uuid: msg.uuid
|
uuid: msg.uuid,
|
||||||
|
toolCalls: msg.toolCalls || undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +557,8 @@ function handleSessionChange(sessionId: string) {
|
|||||||
function handleSubmit(text: string) {
|
function handleSubmit(text: string) {
|
||||||
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
||||||
emit('submit', text)
|
emit('submit', text)
|
||||||
|
// Send text as input to the agent's terminal PTY
|
||||||
|
agentTerminal.sendPrompt(text)
|
||||||
// Real response will arrive via transcript-update WebSocket
|
// Real response will arrive via transcript-update WebSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +577,7 @@ function handleTranscriptDone(text: string) {
|
|||||||
|
|
||||||
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
||||||
emit('submit', text)
|
emit('submit', text)
|
||||||
|
agentTerminal.sendPrompt(text)
|
||||||
// Real response will arrive via transcript-update WebSocket
|
// Real response will arrive via transcript-update WebSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +590,10 @@ function toggleHistory() {
|
|||||||
if (showHistory.value) scrollToBottom()
|
if (showHistory.value) scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTerminal() {
|
||||||
|
showTerminal.value = !showTerminal.value
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') emit('close')
|
if (e.key === 'Escape') emit('close')
|
||||||
}
|
}
|
||||||
@@ -477,6 +604,7 @@ watch(() => props.visible, async (v) => {
|
|||||||
showTranscript.value = false
|
showTranscript.value = false
|
||||||
showHistory.value = false
|
showHistory.value = false
|
||||||
showSettings.value = false
|
showSettings.value = false
|
||||||
|
showTerminal.value = false
|
||||||
messages.length = 0
|
messages.length = 0
|
||||||
idCounter = 0
|
idCounter = 0
|
||||||
sessionStats.value = null
|
sessionStats.value = null
|
||||||
@@ -493,6 +621,10 @@ watch(() => props.visible, async (v) => {
|
|||||||
// Connect WebSocket for real-time updates
|
// Connect WebSocket for real-time updates
|
||||||
connectWs()
|
connectWs()
|
||||||
|
|
||||||
|
// Connect agent terminal (lazy)
|
||||||
|
agentTerminal.connect()
|
||||||
|
agentTerminal.checkStatus()
|
||||||
|
|
||||||
await voice.init()
|
await voice.init()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (props.startRecording) {
|
if (props.startRecording) {
|
||||||
@@ -502,6 +634,8 @@ watch(() => props.visible, async (v) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
disconnectWs()
|
disconnectWs()
|
||||||
|
agentTerminal.disconnect()
|
||||||
|
showTerminal.value = false
|
||||||
if (voice.isRecording.value) {
|
if (voice.isRecording.value) {
|
||||||
voice.stopRecording()
|
voice.stopRecording()
|
||||||
}
|
}
|
||||||
@@ -518,6 +652,7 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
disconnectWs()
|
disconnectWs()
|
||||||
|
agentTerminal.dispose()
|
||||||
voice.cleanup()
|
voice.cleanup()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -592,86 +727,109 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- Conversation content area -->
|
<!-- Conversation content area -->
|
||||||
<div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }">
|
<div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }">
|
||||||
<div
|
<template v-for="(item, idx) in displayMessages" :key="item.msg?.id || item.ids?.join('-') || idx">
|
||||||
v-for="msg in messages"
|
<!-- Tool group: chips compactos -->
|
||||||
:key="msg.id"
|
<div v-if="item.type === 'tool-group'" class="tool-chips-row">
|
||||||
class="chat-msg"
|
<span
|
||||||
:class="msg.role"
|
v-for="tool in item.tools"
|
||||||
>
|
:key="tool.name"
|
||||||
<template v-if="msg.role === 'user'">
|
class="tool-chip"
|
||||||
<div class="msg-bubble user-bubble">{{ msg.content }}</div>
|
:style="{ '--chip-color': tool.meta.color }"
|
||||||
</template>
|
>
|
||||||
|
<span class="chip-icon">{{ tool.meta.icon }}</span>
|
||||||
|
<span class="chip-label">{{ tool.meta.label || tool.name }}</span>
|
||||||
|
<span v-if="tool.count > 1" class="chip-count">{{ tool.count }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Intervention card -->
|
<!-- Regular message -->
|
||||||
<template v-else-if="msg.intervention">
|
<div v-else class="chat-msg" :class="item.msg!.role">
|
||||||
<div class="intervention-card" :class="`intervention--${msg.intervention.type}`">
|
<template v-if="item.msg!.role === 'user'">
|
||||||
<!-- Permission card -->
|
<div class="msg-bubble user-bubble">{{ item.msg!.content }}</div>
|
||||||
<template v-if="msg.intervention.type === 'permission'">
|
</template>
|
||||||
<div class="intv-header">
|
|
||||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
<span class="intv-title">Permission: {{ msg.intervention.toolName }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="intv-detail">{{ msg.content }}</div>
|
|
||||||
<div v-if="!msg.intervention.resolved" class="intv-actions">
|
|
||||||
<button class="intv-btn intv-btn--allow" @click="respondPermission(msg.id, msg.intervention.requestId!, 'allow')">Allow</button>
|
|
||||||
<button class="intv-btn intv-btn--deny" @click="respondPermission(msg.id, msg.intervention.requestId!, 'deny')">Deny</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="intv-resolved" :class="msg.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
|
|
||||||
{{ msg.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Question card (info only) -->
|
<!-- Intervention card -->
|
||||||
<template v-else-if="msg.intervention.type === 'question'">
|
<template v-else-if="item.msg!.intervention">
|
||||||
<div class="intv-header">
|
<div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
|
||||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<!-- Permission card -->
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<template v-if="item.msg!.intervention.type === 'permission'">
|
||||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
<div class="intv-header">
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
</svg>
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
<span class="intv-title">Question</span>
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
</div>
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
<div class="intv-detail">{{ msg.content }}</div>
|
</svg>
|
||||||
<div v-if="msg.intervention.options?.length" class="intv-options">
|
<span class="intv-title">Permission: {{ item.msg!.intervention.toolName }}</span>
|
||||||
<div v-for="(opt, i) in msg.intervention.options" :key="i" class="intv-option">
|
|
||||||
<span class="opt-label">{{ opt.label }}</span>
|
|
||||||
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="intv-detail">{{ item.msg!.content }}</div>
|
||||||
<div class="intv-hint">Respond in terminal</div>
|
<div v-if="!item.msg!.intervention.resolved" class="intv-actions">
|
||||||
</template>
|
<button class="intv-btn intv-btn--allow" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'allow')">Allow</button>
|
||||||
|
<button class="intv-btn intv-btn--deny" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'deny')">Deny</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="intv-resolved" :class="item.msg!.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
|
||||||
|
{{ item.msg!.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Plan card (info only) -->
|
<!-- Question card (info only) -->
|
||||||
<template v-else-if="msg.intervention.type === 'plan'">
|
<template v-else-if="item.msg!.intervention.type === 'question'">
|
||||||
<div class="intv-header">
|
<div class="intv-header">
|
||||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
</svg>
|
||||||
</svg>
|
<span class="intv-title">Question</span>
|
||||||
<span class="intv-title">{{ msg.content }}</span>
|
</div>
|
||||||
</div>
|
<div class="intv-detail">{{ item.msg!.content }}</div>
|
||||||
<div class="intv-hint">Review in terminal</div>
|
<div v-if="item.msg!.intervention.options?.length" class="intv-options">
|
||||||
</template>
|
<div v-for="(opt, i) in item.msg!.intervention.options" :key="i" class="intv-option">
|
||||||
</div>
|
<span class="opt-label">{{ opt.label }}</span>
|
||||||
</template>
|
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="intv-hint">Respond in terminal</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<!-- Plan card (info only) -->
|
||||||
<div class="msg-bubble agent-bubble">
|
<template v-else-if="item.msg!.intervention.type === 'plan'">
|
||||||
<div v-if="msg.status === 'thinking'" class="thinking-inline">
|
<div class="intv-header">
|
||||||
<span class="dot"></span>
|
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<span class="dot"></span>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
<span class="dot"></span>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span class="intv-title">{{ item.msg!.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="intv-hint">Review in terminal</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="agent-text fade-in">{{ msg.content }}</div>
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
<template v-else>
|
||||||
</div>
|
<div class="msg-bubble agent-bubble">
|
||||||
|
<div v-if="item.msg!.status === 'thinking'" class="thinking-inline">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="agent-text fade-in">{{ item.msg!.content }}</div>
|
||||||
|
<!-- Inline tool chips for mixed messages (text + tools) -->
|
||||||
|
<div v-if="item.msg!.toolCalls?.length && !item.msg!.content.startsWith('[Tool calls:')" class="inline-tools">
|
||||||
|
<span
|
||||||
|
v-for="t in item.msg!.toolCalls"
|
||||||
|
:key="t"
|
||||||
|
class="tool-chip-mini"
|
||||||
|
:style="{ '--chip-color': getToolMeta(t).color }"
|
||||||
|
>{{ getToolMeta(t).icon }} {{ getToolMeta(t).label || t }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<TranscriptCard v-if="showTranscript" :voice="voice" @done="handleTranscriptDone" />
|
<TranscriptCard v-if="showTranscript" :voice="voice" @done="handleTranscriptDone" />
|
||||||
<InputSettings
|
<InputSettings
|
||||||
@@ -688,17 +846,27 @@ onBeforeUnmount(() => {
|
|||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<ChatInput
|
<ChatInput
|
||||||
ref="chatInputEl"
|
ref="chatInputEl"
|
||||||
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
|
:placeholder="inputPlaceholder"
|
||||||
:recording="isRecording"
|
:recording="isRecording"
|
||||||
:history-active="showHistory"
|
:history-active="showHistory"
|
||||||
:settings-active="showSettings"
|
:settings-active="showSettings"
|
||||||
|
:terminal-active="showTerminal"
|
||||||
|
:status-dot="statusDot"
|
||||||
:autofocus="visible"
|
:autofocus="visible"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@mic="handleMic"
|
@mic="handleMic"
|
||||||
@toggle-history="toggleHistory"
|
@toggle-history="toggleHistory"
|
||||||
@toggle-settings="toggleSettings"
|
@toggle-settings="toggleSettings"
|
||||||
|
@toggle-terminal="toggleTerminal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent terminal floating window (shares composable instance) -->
|
||||||
|
<AgentTerminal
|
||||||
|
v-model="showTerminal"
|
||||||
|
:agent="agent"
|
||||||
|
:agent-terminal="agentTerminal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -1137,6 +1305,74 @@ onBeforeUnmount(() => {
|
|||||||
50% { border-color: rgba(239, 68, 68, 0.4); }
|
50% { border-color: rgba(239, 68, 68, 0.4); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tool chips row — consecutive tool calls share horizontal space */
|
||||||
|
.tool-chips-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
animation: msg-in 0.2s ease-out;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
background: color-mix(in srgb, var(--chip-color) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
|
||||||
|
color: var(--chip-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-chip:hover {
|
||||||
|
background: color-mix(in srgb, var(--chip-color) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-count {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce gap between consecutive tool rows */
|
||||||
|
.tool-chips-row + .tool-chips-row {
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline tool chips for mixed messages (text + tool calls) */
|
||||||
|
.inline-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-chip-mini {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
color: var(--chip-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.prompt-bar-panel {
|
.prompt-bar-panel {
|
||||||
|
|||||||
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 {
|
function dispose(): void {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
terminal.value?.dispose()
|
if (terminal.value) {
|
||||||
terminal.value = null
|
try { terminal.value.dispose() } catch { /* addon not loaded */ }
|
||||||
|
terminal.value = null
|
||||||
|
}
|
||||||
fitAddon.value = null
|
fitAddon.value = null
|
||||||
isReady.value = false
|
isReady.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,17 @@ export const endpoints = {
|
|||||||
api: '/api'
|
api: '/api'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent terminal helpers
|
||||||
|
export function agentTerminalUrl(agentId: string): string {
|
||||||
|
const base = endpoints.terminal
|
||||||
|
const sep = base.includes('?') ? '&' : '?'
|
||||||
|
return `${base}${sep}session=agent-${agentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalApiUrl(path: string): string {
|
||||||
|
if (isSecure) return `https://${hostname}/ws/terminal${path}`
|
||||||
|
return `http://${hostname}:4103${path}`
|
||||||
|
}
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints)
|
console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface UiConfig {
|
|||||||
terminalBg: string
|
terminalBg: string
|
||||||
terminalBorder: string
|
terminalBorder: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
command?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
import { PORT_TERMINAL } from '../config'
|
import { PORT_TERMINAL } from '../config'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
|
import { setActiveSession, getIncrementalMessages } from '../services/transcript-engine'
|
||||||
|
|
||||||
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
|
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
|
||||||
|
|
||||||
@@ -118,6 +119,30 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
|
|||||||
console.error('[claude-hook] Failed to forward status to terminal server:', e)
|
console.error('[claude-hook] Failed to forward status to terminal server:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Incremental transcript reading for real-time chat
|
||||||
|
if (body.session_id && body.transcript_path) {
|
||||||
|
const agentName = agent || 'main'
|
||||||
|
setActiveSession(agentName, body.session_id, body.transcript_path as string)
|
||||||
|
|
||||||
|
const newMessages = getIncrementalMessages(body.session_id, body.transcript_path as string)
|
||||||
|
if (newMessages.length > 0) {
|
||||||
|
try {
|
||||||
|
await fetch(`http://localhost:${PORT_TERMINAL}/transcript-update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: body.session_id,
|
||||||
|
agent: agentName,
|
||||||
|
messages: newMessages,
|
||||||
|
hookEvent: body.hook_event_name
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[claude-hook] Failed to forward transcript update:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' })
|
return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errorResponse('Invalid JSON body', 400)
|
return errorResponse('Invalid JSON body', 400)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
handleAgentsPlugins, handleAgentsMcpJson,
|
handleAgentsPlugins, handleAgentsMcpJson,
|
||||||
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
|
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
|
||||||
} from './agents'
|
} from './agents'
|
||||||
import { handleTranscript, handleTranscriptSessions } from './transcript'
|
import { handleTranscript, handleTranscriptSessions, handleTranscriptActive, handleClaudeStats, handleClaudeUsage } from './transcript'
|
||||||
|
|
||||||
export async function handleRequest(req: Request): Promise<Response> {
|
export async function handleRequest(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
@@ -278,7 +278,21 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
return handleGitFile(url)
|
return handleGitFile(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude usage limits (estimated)
|
||||||
|
if (path === '/api/claude-usage') {
|
||||||
|
return handleClaudeUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude stats (global)
|
||||||
|
if (path === '/api/claude-stats') {
|
||||||
|
return handleClaudeStats()
|
||||||
|
}
|
||||||
|
|
||||||
// Transcript
|
// Transcript
|
||||||
|
if (path === '/api/transcript/active' && req.method === 'GET') {
|
||||||
|
return handleTranscriptActive(req, url)
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/transcript/sessions' && req.method === 'GET') {
|
if (path === '/api/transcript/sessions' && req.method === 'GET') {
|
||||||
return handleTranscriptSessions()
|
return handleTranscriptSessions()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
import { getTranscriptAnalysis, listSessions } from '../services/transcript-engine'
|
import { getTranscriptAnalysis, listSessions, getActiveSession, resetSessionOffset, getClaudeStats, getClaudeUsage } from '../services/transcript-engine'
|
||||||
import type { TranscriptAnalysis } from '../services/transcript-engine'
|
import type { TranscriptAnalysis } from '../services/transcript-engine'
|
||||||
|
|
||||||
|
export function handleClaudeUsage(): Response {
|
||||||
|
const usage = getClaudeUsage()
|
||||||
|
if (!usage) return errorResponse('Usage data not available', 404)
|
||||||
|
return jsonResponse(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleClaudeStats(): Response {
|
||||||
|
const stats = getClaudeStats()
|
||||||
|
if (!stats) return errorResponse('Stats not available', 404)
|
||||||
|
return jsonResponse(stats)
|
||||||
|
}
|
||||||
|
|
||||||
export function handleTranscriptSessions(): Response {
|
export function handleTranscriptSessions(): Response {
|
||||||
const sessions = listSessions()
|
const sessions = listSessions()
|
||||||
return jsonResponse(sessions)
|
return jsonResponse(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleTranscriptActive(req: Request, url: URL): Response {
|
||||||
|
if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
|
||||||
|
|
||||||
|
const agent = url.searchParams.get('agent') || 'main'
|
||||||
|
const activeSession = getActiveSession(agent)
|
||||||
|
|
||||||
|
const sessionId = activeSession?.sessionId
|
||||||
|
const analysis = getTranscriptAnalysis(sessionId, activeSession?.transcriptPath)
|
||||||
|
|
||||||
|
if (!analysis) return errorResponse('No active session found', 404)
|
||||||
|
|
||||||
|
// Reset offset so future incrementals start from here
|
||||||
|
resetSessionOffset(analysis.sessionId)
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
...analysis,
|
||||||
|
messages: analysis.messages.filter(m => !m.isMeta)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function handleTranscript(req: Request, url: URL, sessionId: string): Response {
|
export function handleTranscript(req: Request, url: URL, sessionId: string): Response {
|
||||||
if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
|
if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
|
||||||
|
|
||||||
@@ -63,7 +95,8 @@ function handleSection(analysis: TranscriptAnalysis, section: string): Response
|
|||||||
version: analysis.version,
|
version: analysis.version,
|
||||||
duration: analysis.duration,
|
duration: analysis.duration,
|
||||||
startTime: analysis.startTime,
|
startTime: analysis.startTime,
|
||||||
endTime: analysis.endTime
|
endTime: analysis.endTime,
|
||||||
|
lastStopReason: analysis.lastStopReason
|
||||||
})
|
})
|
||||||
case 'files':
|
case 'files':
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ interface TerminalSession {
|
|||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent terminal state tracking
|
||||||
|
interface AgentTerminalState {
|
||||||
|
agentId: string
|
||||||
|
sessionId: string
|
||||||
|
command: string
|
||||||
|
startedAt: Date | null
|
||||||
|
isAgentRunning: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentSessions = new Map<string, AgentTerminalState>()
|
||||||
|
|
||||||
|
const AGENT_COMMANDS: Record<string, string> = {
|
||||||
|
'main': 'claude',
|
||||||
|
'ejecutor': 'ejecutor',
|
||||||
|
'nucleo000': 'nucleo000'
|
||||||
|
}
|
||||||
|
|
||||||
// Store active terminal sessions by ID (persistent across reconnections)
|
// Store active terminal sessions by ID (persistent across reconnections)
|
||||||
const sessions = new Map<string, TerminalSession>()
|
const sessions = new Map<string, TerminalSession>()
|
||||||
|
|
||||||
@@ -65,6 +82,16 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
sessions.delete(sessionId)
|
sessions.delete(sessionId)
|
||||||
|
|
||||||
|
// Mark agent as not running if this is an agent session
|
||||||
|
if (sessionId.startsWith('agent-')) {
|
||||||
|
const agentId = sessionId.replace('agent-', '')
|
||||||
|
const state = agentSessions.get(agentId)
|
||||||
|
if (state) {
|
||||||
|
state.isAgentRunning = false
|
||||||
|
console.log(`[Terminal] Agent ${agentId} marked as stopped (exit code ${exitCode})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sessions.set(sessionId, session)
|
sessions.set(sessionId, session)
|
||||||
@@ -74,6 +101,60 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kill an existing session's PTY process
|
||||||
|
export function killSession(sessionId: string): boolean {
|
||||||
|
const session = sessions.get(sessionId)
|
||||||
|
if (!session) return false
|
||||||
|
|
||||||
|
console.log(`[Terminal] Killing session: ${sessionId} (PID: ${session.pty.pid})`)
|
||||||
|
|
||||||
|
// Notify clients before killing
|
||||||
|
for (const ws of session.clients) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'session-restart', sessionId }))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.pty.kill()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Terminal] Error killing PTY for ${sessionId}:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.delete(sessionId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start an agent command in its dedicated session
|
||||||
|
export async function startAgentInSession(agentId: string, force = false): Promise<AgentTerminalState> {
|
||||||
|
const sessionId = `agent-${agentId}`
|
||||||
|
const command = AGENT_COMMANDS[agentId] || agentId
|
||||||
|
|
||||||
|
// If force restart, kill existing session first
|
||||||
|
if (force && sessions.has(sessionId)) {
|
||||||
|
killSession(sessionId)
|
||||||
|
await new Promise(r => setTimeout(r, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getOrCreateSession(sessionId)
|
||||||
|
|
||||||
|
// Write the agent command to the PTY
|
||||||
|
session.pty.write(command + '\r')
|
||||||
|
|
||||||
|
const state: AgentTerminalState = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
command,
|
||||||
|
startedAt: new Date(),
|
||||||
|
isAgentRunning: true
|
||||||
|
}
|
||||||
|
|
||||||
|
agentSessions.set(agentId, state)
|
||||||
|
console.log(`[Terminal] Agent ${agentId} started in session ${sessionId} with command: ${command}`)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
export function startTerminalServer() {
|
export function startTerminalServer() {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT_TERMINAL,
|
port: PORT_TERMINAL,
|
||||||
@@ -146,6 +227,66 @@ export function startTerminalServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent sessions info
|
||||||
|
if (url.pathname === '/agent-sessions' && req.method === 'GET') {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [id, state] of agentSessions) {
|
||||||
|
const session = sessions.get(state.sessionId)
|
||||||
|
result[id] = {
|
||||||
|
...state,
|
||||||
|
pid: session?.pty.pid ?? null,
|
||||||
|
bufferSize: session?.outputBuffer.length ?? 0,
|
||||||
|
clientCount: session?.clients.size ?? 0,
|
||||||
|
sessionExists: !!session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json(result, { headers: corsHeaders })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start agent in session
|
||||||
|
if (url.pathname === '/start-agent' && req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as { agentId: string; force?: boolean }
|
||||||
|
if (!body.agentId) {
|
||||||
|
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
|
||||||
|
}
|
||||||
|
const state = await startAgentInSession(body.agentId, body.force)
|
||||||
|
return Response.json({ success: true, state }, { headers: corsHeaders })
|
||||||
|
} catch (e: any) {
|
||||||
|
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop agent session
|
||||||
|
if (url.pathname === '/stop-agent' && req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as { agentId: string }
|
||||||
|
if (!body.agentId) {
|
||||||
|
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
|
||||||
|
}
|
||||||
|
const sessionId = `agent-${body.agentId}`
|
||||||
|
const killed = killSession(sessionId)
|
||||||
|
if (killed) {
|
||||||
|
const state = agentSessions.get(body.agentId)
|
||||||
|
if (state) state.isAgentRunning = false
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, killed }, { headers: corsHeaders })
|
||||||
|
} catch (e: any) {
|
||||||
|
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcript update broadcast endpoint
|
||||||
|
if (url.pathname === '/transcript-update' && req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
broadcastTranscriptUpdate(body as Record<string, unknown>)
|
||||||
|
return Response.json({ success: true }, { headers: corsHeaders })
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a WebSocket upgrade request
|
// Check if this is a WebSocket upgrade request
|
||||||
const upgradeHeader = req.headers.get('upgrade')
|
const upgradeHeader = req.headers.get('upgrade')
|
||||||
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
||||||
@@ -269,11 +410,22 @@ type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' |
|
|||||||
|
|
||||||
// Broadcast Claude status to ALL clients across ALL sessions
|
// Broadcast Claude status to ALL clients across ALL sessions
|
||||||
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
|
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
|
||||||
|
const agentName = agent || 'main'
|
||||||
|
|
||||||
|
// Track agent running state from sessionStart
|
||||||
|
if (status === 'sessionStart') {
|
||||||
|
const state = agentSessions.get(agentName)
|
||||||
|
if (state) {
|
||||||
|
state.isAgentRunning = true
|
||||||
|
console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'claude-status',
|
type: 'claude-status',
|
||||||
status,
|
status,
|
||||||
tool,
|
tool,
|
||||||
agent: agent || 'main',
|
agent: agentName,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -337,3 +489,24 @@ export function broadcastPermissionRequest(data: Record<string, unknown>) {
|
|||||||
|
|
||||||
console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`)
|
console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast transcript updates to ALL clients
|
||||||
|
export function broadcastTranscriptUpdate(data: Record<string, unknown>) {
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'transcript-update',
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
let clientCount = 0
|
||||||
|
for (const [, session] of sessions) {
|
||||||
|
for (const ws of session.clients) {
|
||||||
|
try {
|
||||||
|
ws.send(message)
|
||||||
|
clientCount++
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Terminal] Transcript update: ${data.hookEvent || 'fetch'} (${(data.messages as any[])?.length || 0} msgs) → ${clientCount} clients`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface TranscriptAnalysis {
|
|||||||
thinkingBlocks: number
|
thinkingBlocks: number
|
||||||
errors: number
|
errors: number
|
||||||
}
|
}
|
||||||
|
lastStopReason: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptMessage {
|
export interface TranscriptMessage {
|
||||||
@@ -83,6 +84,49 @@ export interface SessionInfo {
|
|||||||
model: string
|
model: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClaudeStats {
|
||||||
|
today: {
|
||||||
|
date: string
|
||||||
|
messageCount: number
|
||||||
|
sessionCount: number
|
||||||
|
toolCallCount: number
|
||||||
|
tokensByModel: Record<string, number>
|
||||||
|
} | null
|
||||||
|
modelUsage: Record<string, {
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
cacheReadInputTokens: number
|
||||||
|
cacheCreationInputTokens: number
|
||||||
|
costUSD: number
|
||||||
|
}>
|
||||||
|
totalSessions: number
|
||||||
|
totalMessages: number
|
||||||
|
firstSessionDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeUsage {
|
||||||
|
subscription: { type: string; tier: string; label: string; multiplier: number }
|
||||||
|
today: { messages: number; outputTokens: number; sessions: number }
|
||||||
|
daily: { used: number; limit: number; percent: number }
|
||||||
|
weekly: { used: number; limit: number; percent: number }
|
||||||
|
status: 'normal' | 'elevated' | 'extended' | 'limit_approaching'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_LIMITS: Record<string, { windowMessages: number; dailyEstimate: number; weeklyEstimate: number }> = {
|
||||||
|
pro: { windowMessages: 45, dailyEstimate: 500, weeklyEstimate: 3500 },
|
||||||
|
max_5x: { windowMessages: 225, dailyEstimate: 2500, weeklyEstimate: 17500 },
|
||||||
|
max_20x: { windowMessages: 900, dailyEstimate: 10000, weeklyEstimate: 70000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTier(rateLimitTier: string): { key: string; label: string; multiplier: number } {
|
||||||
|
const match = rateLimitTier.match(/max_(\d+)x/)
|
||||||
|
if (match) {
|
||||||
|
const n = parseInt(match[1])
|
||||||
|
return { key: `max_${n}x`, label: `Max ${n}x`, multiplier: n }
|
||||||
|
}
|
||||||
|
return { key: 'pro', label: 'Pro', multiplier: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Module-level cache ──
|
// ── Module-level cache ──
|
||||||
|
|
||||||
const cache = new Map<string, {
|
const cache = new Map<string, {
|
||||||
@@ -90,6 +134,16 @@ const cache = new Map<string, {
|
|||||||
lastModified: number
|
lastModified: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// ── Active session tracking (for real-time chat) ──
|
||||||
|
|
||||||
|
const activeAgentSessions = new Map<string, {
|
||||||
|
sessionId: string
|
||||||
|
transcriptPath: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Byte offset per session (how far we've read)
|
||||||
|
const readOffsets = new Map<string, number>()
|
||||||
|
|
||||||
// ── Project hash ──
|
// ── Project hash ──
|
||||||
|
|
||||||
function getProjectHash(): string {
|
function getProjectHash(): string {
|
||||||
@@ -214,6 +268,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
|||||||
toolNames: string[]
|
toolNames: string[]
|
||||||
hasThinking: boolean
|
hasThinking: boolean
|
||||||
usage: any
|
usage: any
|
||||||
|
stopReason: string
|
||||||
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
|
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -293,6 +348,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
|||||||
toolNames: [],
|
toolNames: [],
|
||||||
hasThinking: false,
|
hasThinking: false,
|
||||||
usage: null,
|
usage: null,
|
||||||
|
stopReason: '',
|
||||||
pendingToolCalls: []
|
pendingToolCalls: []
|
||||||
}
|
}
|
||||||
assistantChunks.set(msgId, chunk)
|
assistantChunks.set(msgId, chunk)
|
||||||
@@ -300,6 +356,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
|||||||
|
|
||||||
// Take latest usage (streaming chunks repeat usage, last is most accurate)
|
// Take latest usage (streaming chunks repeat usage, last is most accurate)
|
||||||
if (msg.usage) chunk.usage = msg.usage
|
if (msg.usage) chunk.usage = msg.usage
|
||||||
|
if (msg.stop_reason) chunk.stopReason = msg.stop_reason
|
||||||
|
|
||||||
// Process content blocks (each JSONL line typically has one block)
|
// Process content blocks (each JSONL line typically has one block)
|
||||||
if (Array.isArray(msg.content)) {
|
if (Array.isArray(msg.content)) {
|
||||||
@@ -362,7 +419,9 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
|||||||
|
|
||||||
// ── Second pass: assemble assistant messages and finalize tool calls ──
|
// ── Second pass: assemble assistant messages and finalize tool calls ──
|
||||||
let turnIndex = 0
|
let turnIndex = 0
|
||||||
|
let lastStopReason = ''
|
||||||
for (const [, chunk] of assistantChunks) {
|
for (const [, chunk] of assistantChunks) {
|
||||||
|
if (chunk.stopReason) lastStopReason = chunk.stopReason
|
||||||
const text = chunk.textParts.join('\n').trim()
|
const text = chunk.textParts.join('\n').trim()
|
||||||
|
|
||||||
if (text || chunk.toolNames.length > 0) {
|
if (text || chunk.toolNames.length > 0) {
|
||||||
@@ -477,14 +536,17 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
|
|||||||
toolCallCount: toolCalls.length,
|
toolCallCount: toolCalls.length,
|
||||||
thinkingBlocks,
|
thinkingBlocks,
|
||||||
errors
|
errors
|
||||||
}
|
},
|
||||||
|
lastStopReason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Exported API ──
|
// ── Exported API ──
|
||||||
|
|
||||||
export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null {
|
export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null {
|
||||||
const filePath = resolveTranscriptPath(sessionId)
|
const filePath = transcriptPath && existsSync(transcriptPath)
|
||||||
|
? transcriptPath
|
||||||
|
: resolveTranscriptPath(sessionId)
|
||||||
if (!filePath) return null
|
if (!filePath) return null
|
||||||
|
|
||||||
const sid = sessionIdFromPath(filePath)
|
const sid = sessionIdFromPath(filePath)
|
||||||
@@ -511,6 +573,283 @@ export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Path normalization (Windows compat) ──
|
||||||
|
|
||||||
|
function normalizeTranscriptPath(tp: string): string {
|
||||||
|
tp = tp.replace(/\\/g, '/')
|
||||||
|
if (/^\/[a-zA-Z]\//.test(tp)) {
|
||||||
|
tp = tp[1].toUpperCase() + ':' + tp.slice(2)
|
||||||
|
}
|
||||||
|
return tp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active session API (for real-time chat) ──
|
||||||
|
|
||||||
|
export function setActiveSession(agent: string, sessionId: string, transcriptPath: string): void {
|
||||||
|
const normalizedPath = normalizeTranscriptPath(transcriptPath)
|
||||||
|
activeAgentSessions.set(agent, { sessionId, transcriptPath: normalizedPath })
|
||||||
|
|
||||||
|
// Initialize offset to current file size (don't replay old messages as "new")
|
||||||
|
if (!readOffsets.has(sessionId)) {
|
||||||
|
try {
|
||||||
|
const stat = statSync(normalizedPath)
|
||||||
|
readOffsets.set(sessionId, stat.size)
|
||||||
|
} catch {
|
||||||
|
readOffsets.set(sessionId, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSession(agent: string): { sessionId: string; transcriptPath: string } | null {
|
||||||
|
return activeAgentSessions.get(agent) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSessionOffset(sessionId: string): void {
|
||||||
|
readOffsets.set(sessionId, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIncrementalMessages(sessionId: string, transcriptPath?: string): TranscriptMessage[] {
|
||||||
|
// Resolve path
|
||||||
|
let filePath = transcriptPath ? normalizeTranscriptPath(transcriptPath) : null
|
||||||
|
if (!filePath) {
|
||||||
|
const session = [...activeAgentSessions.values()].find(s => s.sessionId === sessionId)
|
||||||
|
filePath = session?.transcriptPath || null
|
||||||
|
}
|
||||||
|
if (!filePath) {
|
||||||
|
filePath = resolveTranscriptPath(sessionId)
|
||||||
|
}
|
||||||
|
if (!filePath || !existsSync(filePath)) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = statSync(filePath)
|
||||||
|
const fileSize = stat.size
|
||||||
|
const offset = readOffsets.get(sessionId) || 0
|
||||||
|
|
||||||
|
if (offset >= fileSize) return []
|
||||||
|
|
||||||
|
// Read only new bytes
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
const newContent = buffer.slice(offset, fileSize).toString('utf8')
|
||||||
|
|
||||||
|
// Update offset
|
||||||
|
readOffsets.set(sessionId, fileSize)
|
||||||
|
|
||||||
|
// Parse new lines
|
||||||
|
const rawLines = newContent.split('\n').filter(l => l.trim())
|
||||||
|
const messages: TranscriptMessage[] = []
|
||||||
|
|
||||||
|
// Track assistant chunks by message.id for consolidation
|
||||||
|
const assistantChunks = new Map<string, {
|
||||||
|
uuid: string
|
||||||
|
timestamp: string
|
||||||
|
textParts: string[]
|
||||||
|
toolNames: string[]
|
||||||
|
hasThinking: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
for (const line of rawLines) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line)
|
||||||
|
|
||||||
|
if (obj.type === 'user') {
|
||||||
|
const msg = obj.message
|
||||||
|
if (!msg) continue
|
||||||
|
|
||||||
|
const isMeta = !!obj.isMeta
|
||||||
|
const text = extractText(msg.content)
|
||||||
|
const hasToolResult = Array.isArray(msg.content) &&
|
||||||
|
msg.content.some((c: any) => c.type === 'tool_result')
|
||||||
|
|
||||||
|
if (text && !hasToolResult && !isMeta) {
|
||||||
|
messages.push({
|
||||||
|
uuid: obj.uuid || crypto.randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: obj.timestamp || new Date().toISOString(),
|
||||||
|
isMeta: false,
|
||||||
|
hasThinking: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (obj.type === 'assistant') {
|
||||||
|
const msg = obj.message
|
||||||
|
if (!msg || msg.role !== 'assistant') continue
|
||||||
|
|
||||||
|
const msgId = msg.id || obj.uuid || 'unknown'
|
||||||
|
let chunk = assistantChunks.get(msgId)
|
||||||
|
if (!chunk) {
|
||||||
|
chunk = {
|
||||||
|
uuid: obj.uuid || crypto.randomUUID(),
|
||||||
|
timestamp: obj.timestamp || new Date().toISOString(),
|
||||||
|
textParts: [],
|
||||||
|
toolNames: [],
|
||||||
|
hasThinking: false
|
||||||
|
}
|
||||||
|
assistantChunks.set(msgId, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'text' && block.text?.trim()) {
|
||||||
|
chunk.textParts.push(block.text)
|
||||||
|
} else if (block.type === 'thinking') {
|
||||||
|
chunk.hasThinking = true
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
chunk.toolNames.push(block.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore progress, file-history-snapshot, summary
|
||||||
|
} catch {
|
||||||
|
// Skip unparseable lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble assistant messages from consolidated chunks
|
||||||
|
for (const [, chunk] of assistantChunks) {
|
||||||
|
const text = chunk.textParts.join('\n').trim()
|
||||||
|
if (text || chunk.toolNames.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
uuid: chunk.uuid,
|
||||||
|
role: 'assistant',
|
||||||
|
content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
|
||||||
|
timestamp: chunk.timestamp,
|
||||||
|
isMeta: false,
|
||||||
|
toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
|
||||||
|
hasThinking: chunk.hasThinking
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronologically
|
||||||
|
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||||
|
|
||||||
|
return messages
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[transcript-engine] Incremental read error:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaudeStats(): ClaudeStats | null {
|
||||||
|
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
|
||||||
|
if (!existsSync(statsPath)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
// Find today's daily activity
|
||||||
|
const todayActivity = raw.dailyActivity?.find((d: any) => d.date === todayStr) || null
|
||||||
|
const todayTokens = raw.dailyModelTokens?.find((d: any) => d.date === todayStr) || null
|
||||||
|
|
||||||
|
const today = todayActivity ? {
|
||||||
|
date: todayStr,
|
||||||
|
messageCount: todayActivity.messageCount || 0,
|
||||||
|
sessionCount: todayActivity.sessionCount || 0,
|
||||||
|
toolCallCount: todayActivity.toolCallCount || 0,
|
||||||
|
tokensByModel: todayTokens?.tokensByModel || {}
|
||||||
|
} : null
|
||||||
|
|
||||||
|
// Model usage
|
||||||
|
const modelUsage: ClaudeStats['modelUsage'] = {}
|
||||||
|
if (raw.modelUsage) {
|
||||||
|
for (const [model, usage] of Object.entries(raw.modelUsage as Record<string, any>)) {
|
||||||
|
modelUsage[model] = {
|
||||||
|
inputTokens: usage.inputTokens || 0,
|
||||||
|
outputTokens: usage.outputTokens || 0,
|
||||||
|
cacheReadInputTokens: usage.cacheReadInputTokens || 0,
|
||||||
|
cacheCreationInputTokens: usage.cacheCreationInputTokens || 0,
|
||||||
|
costUSD: usage.costUSD || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
today,
|
||||||
|
modelUsage,
|
||||||
|
totalSessions: raw.totalSessions || 0,
|
||||||
|
totalMessages: raw.totalMessages || 0,
|
||||||
|
firstSessionDate: raw.firstSessionDate || ''
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[transcript-engine] Error reading stats-cache.json:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaudeUsage(): ClaudeUsage | null {
|
||||||
|
const credentialsPath = join(homedir(), '.claude', '.credentials.json')
|
||||||
|
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
|
||||||
|
|
||||||
|
if (!existsSync(credentialsPath)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Read credentials (never expose tokens)
|
||||||
|
const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'))
|
||||||
|
const oauth = creds.claudeAiOauth || {}
|
||||||
|
const subType = oauth.subscriptionType || 'pro'
|
||||||
|
const rawTier = oauth.rateLimitTier || ''
|
||||||
|
const tier = parseTier(rawTier)
|
||||||
|
const limits = TIER_LIMITS[tier.key] || TIER_LIMITS.pro
|
||||||
|
|
||||||
|
// 2. Read stats-cache for daily + weekly data
|
||||||
|
let todayMessages = 0
|
||||||
|
let todayOutputTokens = 0
|
||||||
|
let todaySessions = 0
|
||||||
|
let weeklyMessages = 0
|
||||||
|
|
||||||
|
if (existsSync(statsPath)) {
|
||||||
|
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10)
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
for (const day of raw.dailyActivity || []) {
|
||||||
|
if (day.date === todayStr) {
|
||||||
|
todayMessages = day.messageCount || 0
|
||||||
|
todaySessions = day.sessionCount || 0
|
||||||
|
}
|
||||||
|
if (day.date >= sevenDaysAgo) {
|
||||||
|
weeklyMessages += day.messageCount || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output tokens for today
|
||||||
|
const todayTokens = (raw.dailyModelTokens || []).find((d: any) => d.date === todayStr)
|
||||||
|
if (todayTokens?.tokensByModel) {
|
||||||
|
todayOutputTokens = Object.values(todayTokens.tokensByModel as Record<string, number>)
|
||||||
|
.reduce((sum: number, v: number) => sum + v, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Calculate percentages
|
||||||
|
const dailyPercent = limits.dailyEstimate > 0
|
||||||
|
? Math.round(todayMessages / limits.dailyEstimate * 100)
|
||||||
|
: 0
|
||||||
|
const weeklyPercent = limits.weeklyEstimate > 0
|
||||||
|
? Math.round(weeklyMessages / limits.weeklyEstimate * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 4. Determine status based on highest usage (daily or weekly)
|
||||||
|
const maxPercent = Math.max(dailyPercent, weeklyPercent)
|
||||||
|
let status: ClaudeUsage['status'] = 'normal'
|
||||||
|
if (maxPercent >= 100) status = 'extended'
|
||||||
|
else if (maxPercent >= 80) status = 'limit_approaching'
|
||||||
|
else if (maxPercent >= 50) status = 'elevated'
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscription: { type: subType, tier: tier.key, label: tier.label, multiplier: tier.multiplier },
|
||||||
|
today: { messages: todayMessages, outputTokens: todayOutputTokens, sessions: todaySessions },
|
||||||
|
daily: { used: todayMessages, limit: limits.dailyEstimate, percent: dailyPercent },
|
||||||
|
weekly: { used: weeklyMessages, limit: limits.weeklyEstimate, percent: weeklyPercent },
|
||||||
|
status
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[transcript-engine] Error reading claude usage:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function listSessions(): SessionInfo[] {
|
export function listSessions(): SessionInfo[] {
|
||||||
const projectDir = getProjectDir()
|
const projectDir = getProjectDir()
|
||||||
if (!existsSync(projectDir)) return []
|
if (!existsSync(projectDir)) return []
|
||||||
|
|||||||
Reference in New Issue
Block a user