feat: voice mic, pixel life layer, enhanced transcript-debug UX

VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble
with voice display, AgentBadge terminal switcher, ChatContainer voice integration,
FloatingTranscriptDebug ocean life enhancements, and terminal registry support.
Remove traefik config.
This commit is contained in:
2026-02-20 12:12:53 -06:00
parent b7f03a777b
commit 220d595568
19 changed files with 2221 additions and 358 deletions

View File

@@ -5,6 +5,7 @@ import Toolbar from './components/Toolbar.vue'
import TorchButton from './components/TorchButton.vue'
import FloatingTerminal from './components/FloatingTerminal.vue'
import FloatingResponse from './components/FloatingResponse.vue'
import { initWhisperSocket } from './services/whisperSocket'
import FloatingVoice from './components/FloatingVoice.vue'
import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue'
import AgentBar from './components/AgentBar.vue'
@@ -70,6 +71,8 @@ function clearDebugLogs() {
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
const transcriptDebugRef = ref<InstanceType<typeof FloatingTranscriptDebug> | null>(null)
const mousePos = ref({ x: 0, y: 0 })
const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore()
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
@@ -104,6 +107,21 @@ function hardRefresh() {
location.reload()
}
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
function handleGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
if (transcriptDebugRef.value) {
transcriptDebugRef.value.openAtCursor(mousePos.value.x, mousePos.value.y)
} else {
showTranscriptDebug.value = !showTranscriptDebug.value
}
}
}
// Voice FAB push-to-talk handlers
function handleVoiceFabClick() {
// If touch just ended, ignore click
@@ -273,6 +291,9 @@ onMounted(async () => {
connectApproval()
fetchApprovalPending()
// Initialize Whisper WebSocket connection early
initWhisperSocket()
// Fire torch connection early (don't await yet)
const torchReady = initTorch()
@@ -342,6 +363,12 @@ onMounted(async () => {
}
})
// Track mouse for Ctrl+E cursor-based opening
document.addEventListener('mousemove', trackMouse)
// Global keyboard shortcut: Ctrl+E toggles Transcript Debug
document.addEventListener('keydown', handleGlobalKeydown)
// Detect virtual keyboard on mobile
if (window.visualViewport) {
const initialHeight = window.visualViewport.height
@@ -357,6 +384,8 @@ onMounted(async () => {
})
onUnmounted(() => {
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleGlobalKeydown)
destroyTorch()
disconnectApproval()
if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout)
@@ -577,7 +606,7 @@ watch(() => route.name, (newPage) => {
<FloatingVoice ref="voiceRef" v-model="showVoice" />
<!-- Floating Transcript Debug -->
<FloatingTranscriptDebug v-model="showTranscriptDebug" />
<FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
<!-- Global Hooks Approval Modal -->
<HooksApprovalModal />

View File

@@ -85,13 +85,6 @@ const renderer = useTerminalRenderer({
}
},
onKeyEvent: (e) => {
// Ctrl+E: Toggle terminal
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
return false
}
// Ctrl+V: Paste from clipboard
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
e.preventDefault()
@@ -216,11 +209,8 @@ function toggleTerminal() {
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
}
function handleKeydown(_e: KeyboardEvent) {
// Reserved for future terminal shortcuts
}
function startDrag(e: MouseEvent | TouchEvent) {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
@@ -32,14 +33,31 @@ const {
processing,
ephemeral,
terminalReady,
openTerminals,
activeTerminalSessionId,
init,
switchAgent,
selectSession,
createNewSession,
switchToTerminal,
closeTerminal,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
const voice = useVoiceInput({ language: 'es-419' })
const {
isRecording: voiceRecording,
transcript: voiceTranscript,
interimTranscript: voiceInterim,
voiceMode,
whisperStatus,
audioDevices,
selectedDeviceId,
lastAudioUrl,
isPlayingAudio,
} = voice
const agents: { id: AgentName; label: string }[] = [
{ id: 'ejecutor', label: 'Ejecutor' },
{ id: 'nucleo000', label: 'nucleo000' },
@@ -62,6 +80,46 @@ const showChrome = computed(() =>
isResizing.value || showSelector.value
)
// ============================================================================
// OCEAN LIFE STATE
// ============================================================================
const currentHour = ref(new Date().getHours())
const isNightTime = computed(() => currentHour.value >= 20 || currentHour.value < 6)
const isNoonTime = computed(() => currentHour.value >= 11 && currentHour.value <= 13)
const isDawnTime = computed(() => currentHour.value >= 5 && currentHour.value <= 7)
const isFridayTime = computed(() => new Date().getDay() === 5)
const isMidnightTime = computed(() => currentHour.value === 0)
const showComet = ref(false)
const showWhale = ref(false)
const showBottle = ref(false)
const showLeviathan = ref(false)
const oceanSeeds = Array.from({ length: 20 }, () => Math.random())
let oceanLifeTimer: ReturnType<typeof setInterval> | null = null
function tickOceanLife() {
currentHour.value = new Date().getHours()
if (!showComet.value && Math.random() < 0.04) {
showComet.value = true
setTimeout(() => { showComet.value = false }, 3000)
}
if (!showWhale.value && Math.random() < 0.07) {
showWhale.value = true
setTimeout(() => { showWhale.value = false }, 95000)
}
if (!showBottle.value && Math.random() < 0.025) {
showBottle.value = true
setTimeout(() => { showBottle.value = false }, 125000)
}
if (!showLeviathan.value && Math.random() < 0.008) {
showLeviathan.value = true
setTimeout(() => { showLeviathan.value = false }, 310000)
}
}
// ============================================================================
// DRAG STATE
// ============================================================================
@@ -84,6 +142,24 @@ type SizeMode = 'pin' | 'medium' | 'large'
const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null
const sizeMode = ref<SizeMode>(savedSize && ['pin', 'medium', 'large'].includes(savedSize) ? savedSize : 'medium')
// Readability overlay opacity
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
const overlayOpacity = ref(savedOverlay !== null ? parseFloat(savedOverlay) : 0.55)
function setOverlayOpacity(val: number) {
overlayOpacity.value = val
localStorage.setItem('transcript-overlay-opacity', String(val))
}
// Input textarea max lines
const savedMaxLines = localStorage.getItem('transcript-input-max-lines')
const inputMaxLines = ref(savedMaxLines !== null ? parseInt(savedMaxLines) : 6)
function setInputMaxLines(val: number) {
inputMaxLines.value = val
localStorage.setItem('transcript-input-max-lines', String(val))
}
// Force mobile (bottom sheet) mode on desktop
const forceMobile = ref(false)
const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
@@ -117,6 +193,18 @@ function cycleSizeMode() {
const isMobile = ref(false)
const sheetHeight = ref(55)
// Virtual keyboard detection
const keyboardVisible = ref(false)
const keyboardHeight = ref(0)
function onVisualViewportResize() {
if (!window.visualViewport) return
const vv = window.visualViewport
const kbH = window.innerHeight - (vv.offsetTop + vv.height)
keyboardHeight.value = Math.max(0, kbH)
keyboardVisible.value = kbH > 100
}
// ============================================================================
// MOBILE DETECTION
// ============================================================================
@@ -213,21 +301,34 @@ const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
const windowStyle = computed((): Record<string, string> => {
if (effectiveMobile.value) {
const isFullScreen = sheetHeight.value >= 100
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const headerH = headerEl ? headerEl.offsetHeight : 40
const style: Record<string, string> = {
position: 'fixed',
left: '0',
right: '0',
bottom: '0',
width: '100%',
}
if (isFullScreen) {
// Full height but respect app header
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const headerH = headerEl ? headerEl.offsetHeight : 40
style.top = `${headerH}px`
style.height = 'auto'
if (keyboardVisible.value) {
// Body is position:fixed so no browser auto-scroll — simple offset works
style.bottom = `${keyboardHeight.value}px`
const available = window.innerHeight - keyboardHeight.value - headerH
if (isFullScreen) {
style.height = `${Math.max(0, available)}px`
} else {
const originalH = window.innerHeight * sheetHeight.value / 100
style.height = `${Math.max(0, Math.min(originalH, available))}px`
}
} else {
style.height = `${sheetHeight.value}dvh`
// No keyboard
style.bottom = '0'
if (isFullScreen) {
style.top = `${headerH}px`
style.height = 'auto'
} else {
style.height = `${sheetHeight.value}dvh`
}
}
return style
}
@@ -335,6 +436,7 @@ function handleSessionSelect(sessionId: string) {
}
function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message)
}
@@ -373,20 +475,29 @@ function handleZoomKey(e: KeyboardEvent) {
// LIFECYCLE
// ============================================================================
onMounted(() => {
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('keydown', handleZoomKey)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onVisualViewportResize)
}
oceanLifeTimer = setInterval(tickOceanLife, 20000)
tickOceanLife()
await voice.init()
})
onBeforeUnmount(() => {
if (oceanLifeTimer) clearInterval(oceanLifeTimer)
disconnectRealtime()
voice.cleanup()
document.removeEventListener('keydown', handleZoomKey)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
window.removeEventListener('resize', checkMobile)
window.visualViewport?.removeEventListener('resize', onVisualViewportResize)
})
</script>
@@ -420,8 +531,49 @@ onBeforeUnmount(() => {
<!-- Titlebar -->
<div class="titlebar">
<!-- Ocean Life Ecosystem -->
<div class="ocean-life">
<!-- Always visible -->
<i class="cr fish-school" :style="{ animationDelay: `-${oceanSeeds[0] * 10}s` }"></i>
<i class="cr fish-school-2" :style="{ animationDelay: `-${oceanSeeds[1] * 14}s` }"></i>
<i class="cr dolphin" :style="{ animationDelay: `-${oceanSeeds[2] * 12}s` }"></i>
<i class="cr jellyfish" :style="{ animationDelay: `-${oceanSeeds[3] * 25}s` }"></i>
<i class="cr seahorse" :style="{ animationDelay: `-${oceanSeeds[4] * 35}s` }"></i>
<i class="cr turtle" :style="{ animationDelay: `-${oceanSeeds[5] * 50}s` }"></i>
<i class="cr pirate-ship" :style="{ animationDelay: `-${oceanSeeds[6] * 180}s` }"></i>
<i class="cr bubble b1" :style="{ animationDelay: `-${oceanSeeds[7] * 6}s` }"></i>
<i class="cr bubble b2" :style="{ animationDelay: `-${oceanSeeds[8] * 9}s` }"></i>
<i class="cr bubble b3" :style="{ animationDelay: `-${oceanSeeds[9] * 11}s` }"></i>
<!-- Night only -->
<template v-if="isNightTime">
<i class="cr satellite" :style="{ animationDelay: `-${oceanSeeds[10] * 15}s` }"></i>
<i class="cr anglerfish" :style="{ animationDelay: `-${oceanSeeds[11] * 40}s` }"></i>
<i class="cr biolum bl1" :style="{ animationDelay: `-${oceanSeeds[12] * 3.5}s` }"></i>
<i class="cr biolum bl2" :style="{ animationDelay: `-${oceanSeeds[13] * 4.5}s` }"></i>
<i class="cr biolum bl3" :style="{ animationDelay: `-${oceanSeeds[14] * 5.5}s` }"></i>
</template>
<!-- Rare random -->
<i v-if="showComet" class="cr comet"></i>
<i v-if="showWhale" class="cr whale" :style="{ animationDelay: `-${oceanSeeds[15] * 90}s` }"></i>
<i v-if="showBottle" class="cr bottle" :style="{ animationDelay: `-${oceanSeeds[16] * 120}s` }"></i>
<i v-if="showLeviathan" class="cr leviathan" :style="{ animationDelay: `-${oceanSeeds[17] * 300}s` }"></i>
<!-- Time-of-day -->
<i v-if="isNoonTime" class="cr sun-ray"></i>
<i v-if="isDawnTime" class="cr dawn-glow"></i>
<!-- Easter eggs -->
<i v-if="isFridayTime" class="cr party-fish" :style="{ animationDelay: `-${oceanSeeds[18] * 8}s` }"></i>
<i v-if="isMidnightTime" class="cr ghost-ship" :style="{ animationDelay: `-${oceanSeeds[19] * 240}s` }"></i>
</div>
<div class="left">
<AgentBadge v-if="selectedAgent" :agent="selectedAgent" :connected="isRealtime" />
<AgentBadge
v-if="selectedAgent"
:agent="selectedAgent"
:connected="isRealtime"
:terminals="openTerminals"
:active-session-id="activeTerminalSessionId"
@switch-terminal="switchToTerminal"
@close-terminal="closeTerminal"
/>
</div>
<div class="window-controls">
<button
@@ -486,7 +638,7 @@ onBeforeUnmount(() => {
<!-- Content -->
<div class="content">
<AquaticBackground />
<div class="readability-overlay" />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<ChatContainer
ref="chatRef"
v-if="conversation"
@@ -500,10 +652,27 @@ onBeforeUnmount(() => {
:sessions="sessions"
:selected-session-id="selectedSessionId"
:sessions-loading="loading"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:audio-devices="audioDevices"
:selected-device-id="selectedDeviceId"
:is-recording="voiceRecording"
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
:overlay-opacity="overlayOpacity"
:input-max-lines="inputMaxLines"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
@create-session="handleCreateSession"
@start-recording="voice.startRecording()"
@stop-recording="voice.stopRecording()"
@set-voice-mode="voice.setMode($event)"
@select-microphone="voice.selectMicrophone($event)"
@play-last-audio="voice.playLastAudio()"
@update:overlay-opacity="setOverlayOpacity"
@update:input-max-lines="setInputMaxLines"
/>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -733,9 +902,12 @@ onBeforeUnmount(() => {
border-bottom: 1px solid rgba(255,255,255,0.04);
user-select: none;
transition: opacity 0.35s ease, transform 0.35s ease;
animation: titlebar-ocean 8s linear infinite;
}
.left {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
@@ -744,6 +916,8 @@ onBeforeUnmount(() => {
}
.window-controls {
position: relative;
z-index: 1;
display: flex;
gap: 2px;
}
@@ -831,7 +1005,6 @@ onBeforeUnmount(() => {
position: absolute;
inset: 0;
z-index: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
@@ -1148,6 +1321,275 @@ onBeforeUnmount(() => {
}
/* ══════════════════════════════════════════════════════════════════════════
OCEAN LIFE ECOSYSTEM
══════════════════════════════════════════════════════════════════════════ */
.ocean-life {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.cr {
position: absolute;
width: 1px;
height: 1px;
background: transparent;
font-style: normal;
}
/* ── 1. Fish School (3 orange fish, swim left) ── */
.fish-school {
top: 16px;
animation: swim-left 10s linear infinite;
box-shadow:
0 0 #f97316, 1px 0 #f97316, -1px 0 #fb923c,
5px 3px #f97316, 6px 3px #f97316, 4px 3px #fb923c,
3px -2px #f97316, 4px -2px #f97316, 2px -2px #fb923c;
}
/* ── 2. Fish School 2 (2 blue fish, swim right) ── */
.fish-school-2 {
top: 20px;
animation: swim-right 14s linear infinite;
box-shadow:
0 0 #818cf8, -1px 0 #818cf8, 1px 0 #a5b4fc,
-4px 2px #818cf8, -5px 2px #818cf8, -3px 2px #a5b4fc;
}
/* ── 3. Dolphin (grey-blue, arc jump) ── */
.dolphin {
top: 14px;
animation: dolphin-arc 12s linear infinite;
box-shadow:
0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #94a3b8, 3px 0 #cbd5e1, -1px 0 #64748b,
0 1px #64748b, 1px 1px #64748b, 2px 1px #94a3b8,
-1px 2px #94a3b8;
}
/* ── 4. Jellyfish (purple, bob) ── */
.jellyfish {
top: 18px;
animation: swim-left 25s linear infinite, jelly-bob 3s ease-in-out infinite;
box-shadow:
0 -1px #c084fc,
-1px 0 #a855f7, 0 0 #a855f7, 1px 0 #a855f7,
0 1px #c084fc,
-1px 2px #7c3aed, 1px 2px #7c3aed,
0 3px #7c3aed;
}
/* ── 5. Seahorse (yellow-orange, slow drift) ── */
.seahorse {
top: 15px;
animation: swim-left 35s linear infinite, jelly-bob 4s ease-in-out infinite;
box-shadow:
0 -1px #fbbf24,
0 0 #f59e0b, 1px 0 #f59e0b,
0 1px #d97706,
0 2px #d97706,
1px 3px #b45309;
}
/* ── 6. Turtle (green, slow) ── */
.turtle {
top: 14px;
animation: swim-left 50s linear infinite;
box-shadow:
2px -1px #4ade80,
-1px 0 #16a34a, 0 0 #16a34a, 1px 0 #22c55e, 2px 0 #22c55e, 3px 0 #16a34a,
-1px 1px #4ade80, 3px 1px #4ade80;
}
/* ── 7. Pirate Ship (dark silhouette, very slow) ── */
.pirate-ship {
top: 7px;
animation: swim-right 180s linear infinite;
box-shadow:
3px -3px #334155, 3px -2px #334155, 3px -1px #475569,
1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #1e293b, 4px 0 #1e293b, 5px 0 #1e293b,
0 1px #1e293b, 1px 1px #1e293b, 2px 1px #1e293b, 3px 1px #1e293b, 4px 1px #1e293b, 5px 1px #1e293b, 6px 1px #1e293b,
1px 2px #0f172a, 2px 2px #0f172a, 3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a;
}
/* ── 8-10. Bubbles (rising white dots) ── */
.bubble {
animation: bubble-rise 6s linear infinite;
box-shadow: 0 0 rgba(255,255,255,0.45);
}
.bubble.b1 { left: 25%; animation-duration: 6s; }
.bubble.b2 { left: 55%; animation-duration: 9s; }
.bubble.b3 { left: 80%; animation-duration: 11s; }
/* ── 11. Satellite (night, metallic + blue panels) ── */
.satellite {
top: 3px;
animation: swim-left 15s linear infinite;
box-shadow:
-2px 0 #3b82f6, -1px 0 #3b82f6, 0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #3b82f6, 3px 0 #3b82f6;
}
/* ── 12. Anglerfish (night, dark body + glowing lure) ── */
.anglerfish {
top: 22px;
animation: swim-left 40s linear infinite;
box-shadow:
-2px -1px #fbbf24, -2px -2px rgba(251,191,36,0.4),
0 0 #1e293b, 1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #334155,
0 1px #0f172a, 1px 1px #0f172a, 2px 1px #1e293b;
}
/* ── 13-15. Bioluminescence (night, pulsing cyan dots) ── */
.biolum {
box-shadow: 0 0 #22d3ee;
animation: bio-pulse 3.5s ease-in-out infinite;
}
.biolum.bl1 { top: 19px; left: 20%; animation-duration: 3.5s; }
.biolum.bl2 { top: 23px; left: 50%; animation-duration: 4.5s; }
.biolum.bl3 { top: 17px; left: 75%; animation-duration: 5.5s; }
/* ── 16. Comet (rare, fast diagonal streak) ── */
.comet {
top: 2px;
right: 0;
animation: comet-streak 2.5s linear forwards;
box-shadow:
0 0 #fef3c7, -1px 0 #fef3c7,
-2px 1px #fde68a, -3px 1px #fde68a,
-4px 2px rgba(251,191,36,0.6),
-5px 3px rgba(251,191,36,0.3),
-6px 4px rgba(251,191,36,0.15);
}
/* ── 17. Whale (rare, large blue silhouette) ── */
.whale {
top: 14px;
animation: swim-left 90s linear infinite;
box-shadow:
4px -1px #2563eb, 5px -1px #2563eb,
1px 0 #1d4ed8, 2px 0 #1d4ed8, 3px 0 #1d4ed8, 4px 0 #2563eb, 5px 0 #2563eb, 6px 0 #2563eb, 7px 0 #1d4ed8,
0 1px #1e40af, 1px 1px #1e40af, 2px 1px #1d4ed8, 3px 1px #1d4ed8, 4px 1px #1d4ed8, 5px 1px #1d4ed8, 6px 1px #1d4ed8, 7px 1px #1e40af, 8px 1px #1e40af,
2px 2px #1e40af, 3px 2px #1e40af, 4px 2px #1e40af, 5px 2px #1e40af, 6px 2px #1e40af,
7px 3px #1e40af;
}
/* ── 18. Bottle (rare, message in a bottle) ── */
.bottle {
top: 11px;
animation: swim-left 120s linear infinite, jelly-bob 5s ease-in-out infinite;
box-shadow:
0 -1px #d97706,
-1px 0 #92400e, 0 0 #92400e, 1px 0 #fef3c7,
-1px 1px #92400e, 0 1px #92400e;
}
/* ── 19. Leviathan (rare, enormous faint shadow) ── */
.leviathan {
top: 20px;
animation: swim-left 300s linear infinite;
opacity: 0.15;
box-shadow:
2px -1px #1e293b, 3px -1px #1e293b, 4px -1px #1e293b, 5px -1px #1e293b, 6px -1px #1e293b, 7px -1px #1e293b, 8px -1px #1e293b, 9px -1px #1e293b,
0 0 #0f172a, 1px 0 #0f172a, 2px 0 #0f172a, 3px 0 #0f172a, 4px 0 #0f172a, 5px 0 #0f172a, 6px 0 #0f172a, 7px 0 #0f172a, 8px 0 #0f172a, 9px 0 #0f172a, 10px 0 #0f172a, 11px 0 #0f172a, 12px 0 #0f172a,
1px 1px #0f172a, 2px 1px #0f172a, 3px 1px #0f172a, 4px 1px #0f172a, 5px 1px #0f172a, 6px 1px #0f172a, 7px 1px #0f172a, 8px 1px #0f172a, 9px 1px #0f172a, 10px 1px #0f172a, 11px 1px #0f172a,
3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a, 6px 2px #0f172a, 7px 2px #0f172a, 8px 2px #0f172a, 9px 2px #0f172a;
}
/* ── 20. Sun Ray (noon, golden beam) ── */
.sun-ray {
top: 0;
left: 40%;
width: 20px;
height: 100%;
background: linear-gradient(180deg, rgba(251,191,36,0.12) 0%, rgba(251,191,36,0.04) 40%, transparent 100%);
animation: sun-ray-pulse 5s ease-in-out infinite;
}
/* ── 21. Dawn Glow (dawn, warm orange) ── */
.dawn-glow {
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, rgba(251,146,60,0.1) 0%, transparent 40%);
animation: sun-ray-pulse 8s ease-in-out infinite;
}
/* ── 22. Party Fish (Fridays, rainbow nyan) ── */
.party-fish {
top: 15px;
animation: swim-right 8s linear infinite;
box-shadow:
-4px 0 #ef4444, -3px 0 #f97316, -2px 0 #eab308, -1px 0 #22c55e, 0 0 #3b82f6, 1px 0 #8b5cf6,
-3px 1px #f97316, -2px 1px #eab308, -1px 1px #22c55e, 0 1px #3b82f6;
}
/* ── 23. Ghost Ship (midnight, ethereal) ── */
.ghost-ship {
top: 7px;
animation: swim-left 240s linear infinite;
opacity: 0.3;
box-shadow:
3px -3px rgba(255,255,255,0.6), 3px -2px rgba(255,255,255,0.5), 3px -1px rgba(255,255,255,0.4),
1px 0 rgba(255,255,255,0.3), 2px 0 rgba(255,255,255,0.3), 3px 0 rgba(255,255,255,0.3), 4px 0 rgba(255,255,255,0.3), 5px 0 rgba(255,255,255,0.3),
0 1px rgba(255,255,255,0.2), 1px 1px rgba(255,255,255,0.2), 2px 1px rgba(255,255,255,0.2), 3px 1px rgba(255,255,255,0.2), 4px 1px rgba(255,255,255,0.2), 5px 1px rgba(255,255,255,0.2), 6px 1px rgba(255,255,255,0.2),
1px 2px rgba(255,255,255,0.15), 2px 2px rgba(255,255,255,0.15), 3px 2px rgba(255,255,255,0.15), 4px 2px rgba(255,255,255,0.15), 5px 2px rgba(255,255,255,0.15);
}
/* ── Ocean Life Keyframes ── */
@keyframes swim-left {
from { left: calc(100% + 15px); }
to { left: -15px; }
}
@keyframes swim-right {
from { left: -15px; }
to { left: calc(100% + 15px); }
}
@keyframes dolphin-arc {
0% { left: calc(100% + 15px); top: 14px; }
35% { top: 14px; }
50% { top: 4px; }
65% { top: 14px; }
100% { left: -15px; top: 14px; }
}
@keyframes jelly-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes bubble-rise {
0% { top: 28px; opacity: 0.5; }
80% { opacity: 0.3; }
100% { top: -2px; opacity: 0; }
}
@keyframes bio-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.5; }
}
@keyframes comet-streak {
0% { top: 1px; left: 100%; opacity: 0.9; }
100% { top: 18px; left: -20px; opacity: 0; }
}
@keyframes sun-ray-pulse {
0%, 100% { opacity: 0.05; }
50% { opacity: 0.18; }
}
@keyframes titlebar-ocean {
from { background-position: 0 0, 0 0; }
to { background-position: 120px 0, 0 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -1157,7 +1599,8 @@ onBeforeUnmount(() => {
min-width: unset;
min-height: unset;
max-width: 100%;
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1),
bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.aero-win.mobile .glass {

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import type { TerminalSlot } from '@/types/transcript-debug'
defineProps<{
agent: string
connected: boolean
terminals: TerminalSlot[]
activeSessionId: string | null
}>()
const emit = defineEmits<{
'switch-terminal': [sessionId: string]
'close-terminal': [sessionId: string]
}>()
const isOpen = ref(false)
@@ -21,6 +29,22 @@ function onClickOutside(e: MouseEvent) {
}
}
function handleSwitch(sessionId: string) {
emit('switch-terminal', sessionId)
isOpen.value = false
}
function handleClose(e: Event, sessionId: string) {
e.stopPropagation()
emit('close-terminal', sessionId)
}
function slotColor(t: TerminalSlot): string {
if (!t.alive) return '#f87171' // PTY dead → red
if (t.clients > 0) return '#4ade80' // alive + connected → green
return '#fbbf24' // alive, no clients (parked) → orange
}
onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</script>
@@ -28,6 +52,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
<template>
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
<span class="agent-label">{{ agent }}</span>
<span v-if="terminals.length > 1" class="term-count">{{ terminals.length }}</span>
<svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges">
<rect x="2" y="4" width="2" height="2" fill="currentColor"/>
<rect x="0" y="2" width="2" height="2" fill="currentColor"/>
@@ -35,7 +60,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</svg>
<Transition name="dropdown">
<div v-if="isOpen" class="dropdown">
<div class="dropdown-item todo">TODO</div>
<div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
<div
v-for="t in terminals"
:key="t.sessionId"
class="dropdown-item terminal-item"
:class="{ active: t.sessionId === activeSessionId }"
@click.stop="handleSwitch(t.sessionId)"
>
<span class="state-dot" :style="{ background: slotColor(t) }" />
<span class="terminal-label">{{ t.label }}</span>
<button class="close-btn" @click="handleClose($event, t.sessionId)" title="Close terminal">
<svg width="6" height="6" viewBox="0 0 6 6">
<line x1="0" y1="0" x2="6" y2="6" stroke="currentColor" stroke-width="1.2"/>
<line x1="6" y1="0" x2="0" y2="6" stroke="currentColor" stroke-width="1.2"/>
</svg>
</button>
</div>
</div>
</Transition>
</div>
@@ -84,6 +125,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
color: #86efac;
}
.term-count {
font-size: 8px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(165, 180, 252, 0.6);
background: rgba(99, 102, 241, 0.2);
padding: 0 3px;
min-width: 12px;
text-align: center;
line-height: 12px;
}
.connected .term-count {
color: rgba(134, 239, 172, 0.6);
background: rgba(34, 197, 94, 0.15);
}
.caret {
color: rgba(165, 180, 252, 0.5);
transition: transform 0.2s ease;
@@ -101,13 +159,16 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 120px;
min-width: 180px;
max-width: 260px;
max-height: 280px;
overflow-y: auto;
background: rgba(8, 8, 18, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(99, 102, 241, 0.2);
z-index: 100;
padding: 4px 0;
padding: 3px 0;
}
.dropdown-item {
@@ -116,12 +177,69 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.dropdown-item.todo {
.dropdown-item.empty {
color: rgba(255, 255, 255, 0.25);
font-style: italic;
text-align: center;
}
.dropdown-item.terminal-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
cursor: pointer;
transition: background 0.1s;
}
.dropdown-item.terminal-item:hover {
background: rgba(99, 102, 241, 0.12);
}
.dropdown-item.terminal-item.active {
background: rgba(99, 102, 241, 0.18);
color: rgba(255, 255, 255, 0.8);
}
.state-dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 0;
}
.terminal-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.dropdown-item.terminal-item:hover .close-btn {
opacity: 1;
}
.close-btn:hover {
color: #fca5a5;
background: rgba(239, 68, 68, 0.2);
}
/* Transition */
@@ -138,4 +256,17 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
opacity: 0;
transform: translateY(-4px);
}
/* Scrollbar */
.dropdown::-webkit-scrollbar {
width: 4px;
}
.dropdown::-webkit-scrollbar-track {
background: transparent;
}
.dropdown::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.2);
}
</style>

View File

@@ -27,6 +27,16 @@ const props = defineProps<{
sessions?: { id: string; firstUserMessage?: string }[]
selectedSessionId?: string | null
sessionsLoading?: boolean
voiceMode?: 'web' | 'whisper'
whisperStatus?: 'offline' | 'loading' | 'ready'
audioDevices?: MediaDeviceInfo[]
selectedDeviceId?: string
isRecording?: boolean
voiceTranscript?: string
lastAudioUrl?: string
isPlayingAudio?: boolean
overlayOpacity?: number
inputMaxLines?: number
}>()
const emit = defineEmits<{
@@ -34,6 +44,13 @@ const emit = defineEmits<{
switchAgent: [agent: AgentName]
selectSession: [sessionId: string]
createSession: []
startRecording: []
stopRecording: []
setVoiceMode: [mode: 'web' | 'whisper']
selectMicrophone: [deviceId: string]
playLastAudio: []
'update:overlayOpacity': [value: number]
'update:inputMaxLines': [value: number]
}>()
const scrollContainer = ref<HTMLElement | null>(null)
@@ -286,6 +303,71 @@ function formatDuration(start: string, end: string): string {
:terminal="terminal ?? null"
/>
</div>
<div v-if="voiceMode" class="selector-row">
<label class="selector-label">Mic</label>
<select
class="session-select"
:value="selectedDeviceId || ''"
@change="emit('selectMicrophone', ($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Select mic...</option>
<option v-for="d in audioDevices" :key="d.deviceId" :value="d.deviceId">
{{ d.label || 'Microphone ' + d.deviceId.slice(0, 8) }}
</option>
</select>
<button
class="mode-toggle-btn"
:class="{ gpu: voiceMode === 'whisper' }"
@click="emit('setVoiceMode', voiceMode === 'whisper' ? 'web' : 'whisper')"
:title="voiceMode === 'whisper' ? 'Switch to Web Speech' : 'Switch to Whisper GPU'"
>
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
</button>
<span
class="whisper-dot"
:class="whisperStatus"
:title="'Whisper: ' + (whisperStatus || 'offline')"
></span>
<button
v-if="lastAudioUrl"
class="play-audio-btn"
:class="{ playing: isPlayingAudio }"
@click="emit('playLastAudio')"
title="Play last recording"
>
<svg v-if="!isPlayingAudio" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
</button>
</div>
<div class="selector-row">
<label class="selector-label">Overlay</label>
<input
type="range"
class="overlay-slider"
min="0"
max="100"
:value="Math.round((overlayOpacity ?? 0.55) * 100)"
@input="emit('update:overlayOpacity', +($event.target as HTMLInputElement).value / 100)"
/>
<span class="overlay-value">{{ Math.round((overlayOpacity ?? 0.55) * 100) }}%</span>
</div>
<div class="selector-row">
<label class="selector-label">Lines</label>
<input
type="range"
class="overlay-slider"
min="1"
max="12"
:value="inputMaxLines ?? 6"
@input="emit('update:inputMaxLines', +($event.target as HTMLInputElement).value)"
/>
<span class="overlay-value">{{ inputMaxLines ?? 6 }}</span>
</div>
</div>
<div ref="scrollContainer" class="messages-scroll">
<template
@@ -367,7 +449,14 @@ function formatDuration(start: string, end: string): string {
<UserInput
:processing="props.processing"
:terminal-ready="props.terminalReady"
:voice-transcript="voiceTranscript"
:is-recording="isRecording"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:max-lines="props.inputMaxLines"
@send="emit('send', $event)"
@start-recording="emit('startRecording')"
@stop-recording="emit('stopRecording')"
/>
<div class="status-bar">
@@ -792,4 +881,124 @@ function formatDuration(start: string, end: string): string {
to { transform: rotate(360deg); }
}
/* ── Mic settings row ── */
.mode-toggle-btn {
padding: 2px 6px;
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 0;
background: transparent;
color: var(--text-muted, rgba(255,255,255,0.4));
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.mode-toggle-btn:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
color: var(--text-secondary, rgba(255,255,255,0.7));
}
.mode-toggle-btn.gpu {
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.25);
color: #4ade80;
}
.whisper-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.whisper-dot.offline {
background: #6b7280;
}
.whisper-dot.loading {
background: #f59e0b;
animation: pulse-dot 1.2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.whisper-dot.ready {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
}
.play-audio-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 0;
background: transparent;
color: var(--text-muted, rgba(255,255,255,0.4));
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.play-audio-btn:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
color: var(--text-primary, rgba(255,255,255,0.8));
}
.play-audio-btn.playing {
color: #f59e0b;
}
/* Overlay slider */
.overlay-slider {
flex: 1;
min-width: 0;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color, rgba(255,255,255,0.08));
border-radius: 0;
outline: none;
cursor: pointer;
}
.overlay-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #0ea5e9;
border: none;
border-radius: 0;
cursor: pointer;
}
.overlay-slider::-moz-range-thumb {
width: 10px;
height: 10px;
background: #0ea5e9;
border: none;
border-radius: 0;
cursor: pointer;
}
.overlay-value {
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: var(--text-muted, rgba(255,255,255,0.4));
min-width: 28px;
text-align: right;
flex-shrink: 0;
}
</style>

View File

@@ -1,16 +1,38 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import VoiceMicButton from './VoiceMicButton.vue'
const props = defineProps<{
processing?: boolean
terminalReady?: boolean
voiceTranscript?: string
isRecording?: boolean
voiceMode?: 'web' | 'whisper'
whisperStatus?: 'offline' | 'loading' | 'ready'
maxLines?: number
}>()
const maxH = computed(() => {
const lines = props.maxLines ?? 6
// line-height is 1.5 at 13px = ~20px per line, plus padding
return lines <= 1 ? '20px' : `${lines * 20}px`
})
const emit = defineEmits<{
send: [message: string]
startRecording: []
stopRecording: []
}>()
const input = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function adjustHeight() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
const notReady = computed(() => props.terminalReady === false)
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
@@ -20,6 +42,7 @@ function handleSend() {
if (!msg || props.processing || notReady.value) return
emit('send', msg)
input.value = ''
nextTick(adjustHeight)
}
function handleKeydown(e: KeyboardEvent) {
@@ -28,6 +51,16 @@ function handleKeydown(e: KeyboardEvent) {
handleSend()
}
}
// Fill textarea with voice transcript
watch(() => props.voiceTranscript, (newText) => {
if (newText && newText.trim()) {
input.value = newText
}
})
// Auto-grow textarea when content changes
watch(input, () => nextTick(adjustHeight))
</script>
<template>
@@ -46,13 +79,24 @@ function handleKeydown(e: KeyboardEvent) {
</div>
<div class="input-container" :class="{ disabled: processing || notReady }">
<textarea
ref="textareaRef"
v-model="input"
class="input-field"
:style="{ maxHeight: maxH }"
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
rows="1"
:disabled="processing || notReady"
@keydown="handleKeydown"
/>
<VoiceMicButton
v-if="voiceMode"
:is-recording="isRecording ?? false"
:voice-mode="voiceMode"
:whisper-status="whisperStatus ?? 'offline'"
:disabled="processing || notReady"
@start="emit('startRecording')"
@stop="emit('stopRecording')"
/>
<button
class="send-btn"
:disabled="isDisabled"
@@ -139,7 +183,7 @@ function handleKeydown(e: KeyboardEvent) {
line-height: 1.5;
resize: none;
min-height: 20px;
max-height: 120px;
overflow-y: auto;
padding: 0.15rem 0.25rem;
font-family: inherit;
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedUserMessage } from '@/types/transcript-debug'
import MarkdownContent from './MarkdownContent.vue'
const props = defineProps<{
message: ParsedUserMessage
@@ -14,6 +15,57 @@ const emit = defineEmits<{
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
// ── Command / special message detection ──
type CommandInfo =
| { type: 'caveat'; text: string }
| { type: 'command'; name: string; message: string; args: string }
| { type: 'stdout'; text: string }
| { type: 'interrupted' }
| { type: 'meta-action'; text: string }
| null
const commandInfo = computed<CommandInfo>(() => {
const c = props.message.content
if (!c) return null
// [Request interrupted by user ...]
if (c.includes('[Request interrupted by user')) {
return { type: 'interrupted' }
}
// Meta messages: "Continue from where you left off", etc.
if (props.message.isMeta) {
return { type: 'meta-action', text: c.trim() }
}
// <local-command-caveat>...</local-command-caveat>
const caveatMatch = c.match(/<local-command-caveat>([\s\S]*?)<\/local-command-caveat>/)
if (caveatMatch) return { type: 'caveat', text: caveatMatch[1].trim() }
// <command-name>...</command-name>
const cmdNameMatch = c.match(/<command-name>([\s\S]*?)<\/command-name>/)
if (cmdNameMatch) {
const msgMatch = c.match(/<command-message>([\s\S]*?)<\/command-message>/)
const argsMatch = c.match(/<command-args>([\s\S]*?)<\/command-args>/)
return {
type: 'command',
name: cmdNameMatch[1].trim(),
message: msgMatch?.[1]?.trim() || '',
args: argsMatch?.[1]?.trim() || ''
}
}
// <local-command-stdout>...</local-command-stdout>
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)
if (stdoutMatch !== null && c.includes('<local-command-stdout>')) {
return { type: 'stdout', text: stdoutMatch[1].trim() }
}
return null
})
const isCommand = computed(() => commandInfo.value !== null)
function formatTime(ts: string): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
@@ -21,7 +73,65 @@ function formatTime(ts: string): string {
</script>
<template>
<div :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
<!-- COMMAND MESSAGE (compact) -->
<div v-if="isCommand" class="cmd-row">
<!-- Caveat: system note about local commands -->
<template v-if="commandInfo!.type === 'caveat'">
<span class="cmd-icon cmd-icon-caveat">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</span>
<span class="cmd-label cmd-caveat-text">local command caveat</span>
</template>
<!-- Command invocation -->
<template v-else-if="commandInfo!.type === 'command'">
<span class="cmd-icon">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</span>
<code class="cmd-name">{{ (commandInfo as any).name }}</code>
<span v-if="(commandInfo as any).args" class="cmd-args">{{ (commandInfo as any).args }}</span>
</template>
<!-- Stdout -->
<template v-else-if="commandInfo!.type === 'stdout'">
<span class="cmd-icon cmd-icon-out">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
</svg>
</span>
<span v-if="(commandInfo as any).text" class="cmd-stdout">{{ (commandInfo as any).text }}</span>
<span v-else class="cmd-empty">no output</span>
</template>
<!-- Interrupted -->
<template v-else-if="commandInfo!.type === 'interrupted'">
<span class="cmd-icon cmd-icon-interrupted">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/>
</svg>
</span>
<span class="cmd-label cmd-interrupted-text">interrupted by user</span>
</template>
<!-- Meta action (continue, etc.) -->
<template v-else-if="commandInfo!.type === 'meta-action'">
<span class="cmd-icon cmd-icon-meta">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</span>
<span class="cmd-label cmd-meta-text">{{ (commandInfo as any).text }}</span>
</template>
<span class="cmd-time">{{ formatTime(message.timestamp) }}</span>
</div>
<!-- NORMAL USER MESSAGE -->
<div v-else :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
<div class="divider-line" />
<div class="divider-content">
<div class="divider-header">
@@ -46,25 +156,142 @@ function formatTime(ts: string): string {
</button>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="divider-text">{{ message.content }}</div>
<div class="divider-text">
<MarkdownContent :content="message.content" />
</div>
</div>
</div>
</template>
<style scoped>
/* ══════════════════════════════════════
Command row — compact inline display
══════════════════════════════════════ */
.cmd-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.5rem;
margin: 0.1rem 0;
font-size: 11px;
color: var(--text-muted);
opacity: 0.55;
transition: opacity 0.15s;
}
.cmd-row:hover {
opacity: 0.85;
}
.cmd-icon {
display: flex;
align-items: center;
color: #64748b;
flex-shrink: 0;
}
.cmd-icon-caveat { color: #f59e0b; }
.cmd-icon-out { color: #64748b; }
.cmd-label {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
}
.cmd-name {
font-size: 11px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
padding: 0 0.3rem;
border-radius: 3px;
}
.cmd-args {
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
}
.cmd-stdout {
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.cmd-empty {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
opacity: 0.6;
}
.cmd-caveat-text {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-icon-interrupted { color: #ef4444; }
.cmd-interrupted-text {
color: rgba(239, 68, 68, 0.7);
font-weight: 500;
font-style: normal;
}
.cmd-icon-meta { color: #f59e0b; }
.cmd-meta-text {
color: rgba(251, 191, 36, 0.65);
font-weight: 500;
font-style: normal;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-time {
margin-left: auto;
font-size: 9px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
opacity: 0.7;
flex-shrink: 0;
}
/* ══════════════════════════════════════
Normal user message
══════════════════════════════════════ */
.user-divider {
width: 100%;
margin: 0.75rem 0 0.25rem;
}
.divider-line {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(129, 140, 248, 0.2) 10%, rgba(129, 140, 248, 0.2) 90%, transparent);
margin-bottom: 0.5rem;
display: none;
}
.divider-content {
padding: 0 0.25rem;
padding: 0.45rem 0.65rem;
background: linear-gradient(170deg, rgba(148, 163, 184, 0.10) 0%, rgba(100, 116, 139, 0.15) 50%, rgba(71, 85, 105, 0.12) 100%);
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(0, 0, 0, 0.12);
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
border-radius: 8px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.07),
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
}
.divider-header {
@@ -103,19 +330,34 @@ function formatTime(ts: string): string {
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
white-space: pre-wrap;
word-break: break-word;
}
.divider-text :deep(.md-content) {
font-size: inherit;
line-height: inherit;
color: inherit;
}
/* Tighten markdown spacing inside user bubbles */
.divider-text :deep(.md-content p) {
margin: 0.15em 0;
}
.divider-text :deep(.md-content pre) {
margin: 0.4em 0;
font-size: 11px;
}
.divider-text :deep(.md-content code) {
font-size: 0.9em;
}
/* Meta messages: dimmed */
.user-divider.meta {
opacity: 0.45;
}
.user-divider.meta .divider-line {
background: linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.15) 10%, rgba(251, 191, 36, 0.15) 90%, transparent);
}
/* Collapse button */
.collapse-btn {
display: inline-flex;
@@ -155,10 +397,6 @@ function formatTime(ts: string): string {
opacity: 0.6;
}
.user-divider.optimistic .divider-line {
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1) 10%, rgba(99, 102, 241, 0.1) 90%, transparent);
}
.sending-badge {
display: inline-flex;
align-items: center;

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
const props = defineProps<{
isRecording: boolean
voiceMode: 'web' | 'whisper'
whisperStatus: 'offline' | 'loading' | 'ready'
disabled?: boolean
}>()
const emit = defineEmits<{
start: []
stop: []
}>()
// ── Interaction state machine ──
// idle → pointerdown → wait 250ms
// if pointerup < 250ms → "click" → locked (recording until next press+release)
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
// locked → pointerdown → stopping
// stopping → pointerup → stop recording → idle
// ptt → pointerup → wait 500ms trail → stop recording → idle
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
const mode = ref<Mode>('idle')
const HOLD_THRESHOLD = 250
const TRAIL_BUFFER = 500
let holdTimer: number | null = null
let trailTimer: number | null = null
function clearTimers() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
}
function onPointerDown(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
// Capture pointer so we get pointerup even if cursor leaves button
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
if (mode.value === 'idle') {
// Start hold detection
holdTimer = window.setTimeout(() => {
holdTimer = null
// Still holding after 250ms → PTT mode
mode.value = 'ptt'
emit('start')
}, HOLD_THRESHOLD)
} else if (mode.value === 'locked') {
// Second press while locked → prepare to stop on release
mode.value = 'stopping'
}
}
function onPointerUp(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
if (mode.value === 'idle' && holdTimer) {
// Quick click (< 250ms) → lock mode
clearTimers()
mode.value = 'locked'
emit('start')
} else if (mode.value === 'ptt') {
// Release from PTT → 500ms trailing buffer then stop
clearTimers()
trailTimer = window.setTimeout(() => {
trailTimer = null
mode.value = 'idle'
emit('stop')
}, TRAIL_BUFFER)
} else if (mode.value === 'stopping') {
// Release from second press → stop immediately
clearTimers()
mode.value = 'idle'
emit('stop')
}
}
onBeforeUnmount(() => {
clearTimers()
})
</script>
<template>
<button
class="mic-btn"
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
:disabled="disabled"
@pointerdown="onPointerDown"
@pointerup="onPointerUp"
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
>
<!-- Mic icon -->
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<!-- Stop icon (square) when recording -->
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
<!-- Mode badge -->
<span class="mode-badge" :class="voiceMode">
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
</span>
</button>
</template>
<style scoped>
.mic-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--bg-hover, rgba(255,255,255,0.06));
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 6px;
color: var(--text-muted, #888);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
touch-action: none;
user-select: none;
}
.mic-btn:hover:not(:disabled) {
background: var(--bg-hover, rgba(255,255,255,0.1));
color: var(--text-primary, #ccc);
border-color: var(--text-muted, rgba(255,255,255,0.15));
}
.mic-btn.recording {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: rgba(239, 68, 68, 0.5);
color: white;
animation: rec-pulse 1.5s ease-in-out infinite;
}
.mic-btn.recording.ptt {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border-color: rgba(249, 115, 22, 0.5);
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
}
.mic-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.mode-badge {
position: absolute;
bottom: -4px;
right: -4px;
font-size: 7px;
font-weight: 700;
font-family: 'Courier New', monospace;
padding: 1px 3px;
border-radius: 3px;
line-height: 1;
letter-spacing: 0.3px;
pointer-events: none;
}
.mode-badge.whisper {
background: rgba(16, 185, 129, 0.9);
color: white;
}
.mode-badge.web {
background: rgba(59, 130, 246, 0.9);
color: white;
}
@keyframes rec-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
}
@keyframes rec-pulse-ptt {
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
}
</style>

View File

@@ -9,6 +9,7 @@ import {
JellyfishDrift,
EventOverlay,
EdgeFade,
PixelLife,
} from './layers'
const { start, stop } = useAquaticEvents()
@@ -33,6 +34,7 @@ onBeforeUnmount(() => stop())
<BubbleStream />
<FishSchool />
<JellyfishDrift />
<PixelLife />
<EventOverlay />
<EdgeFade />
</div>

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAquaticState } from '../useAquaticState'
const { activeEventModifiers } = useAquaticState()
const pixelSeeds = Array.from({ length: 15 }, () => Math.random())
// All swimming creatures are rare — only stationary ones always visible
const showSeahorse = ref(false)
const showOctopus = ref(false)
const showCrab = ref(false)
const showEel = ref(false)
const showPufferfish = ref(false)
const showShrimp = ref(false)
const showNautilus = ref(false)
const showSwordfish = ref(false)
const showStarfish = ref(false)
const showAnchor = ref(false)
const showSubmarine = ref(false)
const showDiver = ref(false)
let tickTimer: ReturnType<typeof setInterval> | null = null
function tickPixelLife() {
// Common creatures — low chance each tick
showSeahorse.value = Math.random() < 0.06
showOctopus.value = Math.random() < 0.05
showCrab.value = Math.random() < 0.06
showEel.value = Math.random() < 0.04
showPufferfish.value = Math.random() < 0.05
showShrimp.value = Math.random() < 0.06
showNautilus.value = Math.random() < 0.04
showSwordfish.value = Math.random() < 0.03
showStarfish.value = Math.random() < 0.05
// Very rare creatures
showAnchor.value = Math.random() < 0.01
showSubmarine.value = Math.random() < 0.008
showDiver.value = Math.random() < 0.015
}
onMounted(() => {
tickPixelLife()
tickTimer = setInterval(tickPixelLife, 60000)
})
onBeforeUnmount(() => {
if (tickTimer) clearInterval(tickTimer)
})
</script>
<template>
<div class="pixel-life">
<!-- Swimming creatures (all rare) -->
<i v-if="showSeahorse" class="px seahorse" :style="{ animationDelay: `-${pixelSeeds[0] * 160}s` }"></i>
<i v-if="showOctopus" class="px octopus" :style="{ animationDelay: `-${pixelSeeds[1] * 200}s` }"></i>
<i v-if="showCrab" class="px crab" :style="{ animationDelay: `-${pixelSeeds[2] * 120}s` }"></i>
<i v-if="showEel" class="px eel" :style="{ animationDelay: `-${pixelSeeds[3] * 70}s` }"></i>
<i v-if="showPufferfish" class="px pufferfish" :style="{ animationDelay: `-${pixelSeeds[4] * 140}s` }"></i>
<i v-if="showShrimp" class="px shrimp" :style="{ animationDelay: `-${pixelSeeds[5] * 50}s` }"></i>
<i v-if="showNautilus" class="px nautilus" :style="{ animationDelay: `-${pixelSeeds[6] * 240}s` }"></i>
<i v-if="showSwordfish" class="px swordfish" :style="{ animationDelay: `-${pixelSeeds[7] * 25}s` }"></i>
<i v-if="showStarfish" class="px starfish-walk" :style="{ animationDelay: `-${pixelSeeds[8] * 360}s` }"></i>
<!-- Stationary creatures (always visible) -->
<i class="px treasure"></i>
<i class="px sea-anemone"></i>
<i class="px clam"></i>
<!-- Very rare creatures -->
<i v-if="showAnchor" class="px anchor"></i>
<i v-if="showSubmarine" class="px submarine" :style="{ animationDelay: `-${pixelSeeds[11] * 320}s` }"></i>
<i v-if="showDiver" class="px diver" :style="{ animationDelay: `-${pixelSeeds[12] * 180}s` }"></i>
</div>
</template>
<style scoped>
/* ══════════════════════════════════════════════════════════════════════════
Pixel Life — box-shadow pixel art creatures for the main background
══════════════════════════════════════════════════════════════════════════ */
.pixel-life {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 8;
}
.px {
position: absolute;
width: 1px;
height: 1px;
background: transparent;
font-style: normal;
transform: scale(3);
transform-origin: center;
will-change: transform;
}
/* ── Keyframes ── */
@keyframes px-swim-left {
from { left: calc(100% + 20px); }
to { left: -20px; }
}
@keyframes px-swim-right {
from { left: -20px; }
to { left: calc(100% + 20px); }
}
@keyframes px-bob {
0%, 100% { transform: scale(3) translateY(0); }
50% { transform: scale(3) translateY(-4px); }
}
@keyframes px-sine {
0% { transform: scale(3) translateY(0); }
25% { transform: scale(3) translateY(-6px); }
50% { transform: scale(3) translateY(0); }
75% { transform: scale(3) translateY(6px); }
100% { transform: scale(3) translateY(0); }
}
@keyframes px-sink {
from { top: -10px; }
to { top: calc(100% + 10px); }
}
@keyframes px-puff {
0%, 100% { transform: scale(3); }
50% { transform: scale(4.5); }
}
@keyframes px-pulse-glow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
@keyframes px-tentacle {
0%, 100% { transform: scale(3) translateY(0) skewX(0deg); }
25% { transform: scale(3) translateY(-2px) skewX(2deg); }
75% { transform: scale(3) translateY(2px) skewX(-2deg); }
}
@keyframes px-clam-open {
0%, 100% { transform: scale(3) scaleY(1); }
50% { transform: scale(3) scaleY(0.5); }
}
/* ── 1. Seahorse — yellow-orange, gentle bob, swims left ── */
.seahorse {
top: 40%;
animation:
px-swim-left 160s linear infinite,
px-bob 8s ease-in-out infinite;
box-shadow:
1px -2px #fbbf24, 2px -2px #fbbf24,
0 -1px #f59e0b, 1px -1px #f59e0b, 2px -1px #f59e0b,
0 0 #f59e0b, 1px 0 #d97706,
0 1px #d97706, 1px 1px #d97706,
0 2px #b45309, 1px 2px #b45309,
-1px 3px #b45309, 0 3px #92400e,
-1px 4px #92400e;
}
/* ── 2. Octopus — purple-red, 4 trailing tentacles, swims right ── */
.octopus {
top: 55%;
animation: px-swim-right 200s linear infinite;
box-shadow:
1px -1px #a855f7, 2px -1px #a855f7,
0 0 #9333ea, 1px 0 #9333ea, 2px 0 #9333ea, 3px 0 #a855f7,
0 1px #7c3aed, 1px 1px #7c3aed, 2px 1px #7c3aed, 3px 1px #7c3aed,
-1px 2px #6d28d9, 0 2px #7c3aed, 2px 2px #7c3aed, 3px 2px #6d28d9,
-1px 3px #5b21b6, 1px 3px #6d28d9, 3px 3px #5b21b6,
-2px 4px #4c1d95, 0 4px #5b21b6, 2px 4px #5b21b6, 4px 4px #4c1d95;
}
/* ── 3. Crab — red-orange, walking along bottom ── */
.crab {
top: 85%;
animation: px-swim-right 120s linear infinite;
box-shadow:
-2px -1px #dc2626, 4px -1px #dc2626,
-2px 0 #ef4444, -1px 0 #ef4444, 4px 0 #ef4444, 3px 0 #ef4444,
0 0 #b91c1c, 1px 0 #dc2626, 2px 0 #dc2626,
0 1px #991b1b, 1px 1px #b91c1c, 2px 1px #b91c1c,
-1px 2px #7f1d1d, 0 2px #991b1b, 2px 2px #991b1b, 3px 2px #7f1d1d;
}
/* ── 4. Eel — green-dark, sinuous, swims left ── */
.eel {
top: 45%;
animation:
px-swim-left 70s linear infinite,
px-sine 4s ease-in-out infinite;
box-shadow:
0 0 #16a34a, 1px 0 #15803d, 2px 0 #166534, 3px 0 #14532d,
4px 0 #166534, 5px 0 #15803d, 6px 0 #16a34a, 7px 0 #15803d,
1px 1px #22c55e, 2px 1px #22c55e, 3px 1px #22c55e, 5px 1px #22c55e, 6px 1px #22c55e,
0 -1px #fbbf24;
}
/* ── 5. Pufferfish — yellow, inflates via scale pulse ── */
.pufferfish {
top: 35%;
animation:
px-swim-left 140s linear infinite,
px-puff 10s ease-in-out infinite;
box-shadow:
0 -1px #fbbf24, 1px -1px #fbbf24,
-1px 0 #f59e0b, 0 0 #eab308, 1px 0 #eab308, 2px 0 #f59e0b,
-1px 1px #f59e0b, 0 1px #eab308, 1px 1px #eab308, 2px 1px #f59e0b,
0 2px #d97706, 1px 2px #d97706,
-2px 0 #ca8a04, 3px 0 #ca8a04, 0 -2px #ca8a04, 1px -2px #ca8a04,
0 0 #1e293b;
}
/* ── 6. Shrimp — pink-red, dart near bottom ── */
.shrimp {
top: 80%;
animation: px-swim-right 50s linear infinite;
box-shadow:
0 0 #fb7185, 1px 0 #f43f5e, 2px 0 #e11d48, 3px 0 #be123c,
0 1px #fda4af, 1px 1px #fb7185, 2px 1px #f43f5e,
-1px -1px rgba(251,113,133,0.6), -2px -2px rgba(251,113,133,0.3),
4px 0 #9f1239, 5px -1px #881337;
}
/* ── 7. Nautilus — cream-brown spiral shell, swims left ── */
.nautilus {
top: 50%;
animation:
px-swim-left 240s linear infinite,
px-bob 12s ease-in-out infinite;
box-shadow:
0 -1px #d4a574, 1px -1px #c2956a,
-1px 0 #d4a574, 0 0 #b08050, 1px 0 #a0714a, 2px 0 #c2956a,
-1px 1px #c2956a, 0 1px #8b6040, 1px 1px #a0714a, 2px 1px #b08050,
0 2px #c2956a, 1px 2px #d4a574,
-2px 1px #fef3c7, -2px 2px #fde68a;
}
/* ── 8. Swordfish — silver-blue, fast streak ── */
.swordfish {
top: 25%;
animation: px-swim-left 25s linear infinite;
box-shadow:
-3px 0 #94a3b8, -2px 0 #94a3b8,
-1px 0 #64748b, 0 0 #475569, 1px 0 #475569,
2px 0 #334155, 3px 0 #334155, 4px 0 #475569, 5px 0 #64748b,
1px 1px #94a3b8, 2px 1px #94a3b8, 3px 1px #94a3b8, 4px 1px #94a3b8,
2px -1px #334155, 3px -1px #334155,
6px -1px #475569, 6px 1px #475569;
}
/* ── 9. Starfish — orange, very slow crawl along floor ── */
.starfish-walk {
top: 88%;
animation: px-swim-right 360s linear infinite;
box-shadow:
0 0 #f97316, 1px 0 #ea580c,
0 -2px #fb923c, 1px -2px #fb923c,
-2px 0 #fb923c, 3px 0 #fb923c,
-1px 2px #fb923c, 2px 2px #fb923c,
0 -1px #f97316, 1px -1px #f97316,
-1px 0 #f97316, 2px 0 #f97316,
0 1px #ea580c, 1px 1px #ea580c,
-1px 1px #f97316, 2px 1px #f97316;
}
/* ── 10. Anchor — grey iron, sinks slowly (very rare) ── */
.anchor {
left: 70%;
animation: px-sink 100s linear forwards;
box-shadow:
0 -3px #6b7280, 1px -3px #6b7280,
-1px -2px #6b7280, 2px -2px #6b7280,
0 -2px #9ca3af, 1px -2px #9ca3af,
0 -1px #4b5563, 1px -1px #4b5563,
0 0 #4b5563, 1px 0 #4b5563,
0 1px #4b5563, 1px 1px #4b5563,
0 2px #374151, 1px 2px #374151,
-2px 2px #6b7280, -1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #6b7280,
-2px 3px #374151, 3px 3px #374151,
-1px 3px #4b5563, 2px 3px #4b5563;
}
/* ── 11. Treasure — brown chest with gold gleam (stationary) ── */
.treasure {
top: 90%;
left: 25%;
animation: px-pulse-glow 10s ease-in-out infinite;
box-shadow:
0 -1px #92400e, 1px -1px #92400e, 2px -1px #92400e, 3px -1px #92400e,
-1px 0 #78350f, 0 0 #78350f, 1px 0 #92400e, 2px 0 #92400e, 3px 0 #78350f, 4px 0 #78350f,
-1px 1px #78350f, 0 1px #78350f, 1px 1px #92400e, 2px 1px #92400e, 3px 1px #78350f, 4px 1px #78350f,
1px 0 #fbbf24, 2px 0 #fbbf24,
1px -1px #fde68a;
}
/* ── 12. Submarine — grey with yellow light (very rare), swims right ── */
.submarine {
top: 30%;
animation: px-swim-right 320s linear infinite;
box-shadow:
3px -2px #6b7280, 3px -1px #6b7280,
0 0 #4b5563, 1px 0 #4b5563, 2px 0 #4b5563, 3px 0 #4b5563, 4px 0 #4b5563, 5px 0 #4b5563,
-1px 1px #374151, 0 1px #374151, 1px 1px #374151, 2px 1px #374151, 3px 1px #374151, 4px 1px #374151, 5px 1px #374151, 6px 1px #374151,
0 2px #4b5563, 1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #4b5563, 4px 2px #4b5563, 5px 2px #4b5563,
1px 1px #fbbf24, 4px 1px #fbbf24,
-2px 0 #9ca3af, -2px 1px #9ca3af, -2px 2px #9ca3af;
}
/* ── 13. Diver — black suit, white mask, bubble trail (very rare) ── */
.diver {
top: 40%;
animation: px-swim-left 180s linear infinite;
box-shadow:
0 -1px #f8fafc, 1px -1px #f8fafc,
0 0 #1e293b, 1px 0 #1e293b,
0 1px #1e293b, 1px 1px #1e293b,
0 2px #0f172a, 1px 2px #0f172a,
-1px 3px #1e293b, 2px 3px #1e293b,
2px 0 #475569, 2px 1px #475569,
-1px -2px rgba(255,255,255,0.5), -2px -3px rgba(255,255,255,0.3), -3px -4px rgba(255,255,255,0.15);
}
/* ── 14. Sea Anemone — pink-purple, tentacle wave (stationary) ── */
.sea-anemone {
top: 92%;
left: 60%;
animation: px-tentacle 12s ease-in-out infinite;
box-shadow:
-1px -2px #e879f9, 0 -2px #d946ef, 1px -2px #c026d3, 2px -2px #e879f9,
-1px -1px #d946ef, 0 -1px #c026d3, 1px -1px #d946ef, 2px -1px #c026d3,
0 0 #a21caf, 1px 0 #a21caf,
0 1px #86198f, 1px 1px #86198f;
}
/* ── 15. Clam — grey shell, pearl gleam inside (stationary) ── */
.clam {
top: 90%;
left: 82%;
animation: px-clam-open 16s ease-in-out infinite;
box-shadow:
0 -1px #9ca3af, 1px -1px #9ca3af, 2px -1px #9ca3af,
-1px 0 #6b7280, 0 0 #6b7280, 1px 0 #6b7280, 2px 0 #6b7280, 3px 0 #6b7280,
-1px 1px #4b5563, 0 1px #4b5563, 1px 1px #4b5563, 2px 1px #4b5563, 3px 1px #4b5563,
0 2px #6b7280, 1px 2px #6b7280, 2px 2px #6b7280,
1px 0 #fef3c7;
}
</style>

View File

@@ -7,3 +7,4 @@ export { default as FishSchool } from './FishSchool.vue'
export { default as JellyfishDrift } from './JellyfishDrift.vue'
export { default as EventOverlay } from './EventOverlay.vue'
export { default as EdgeFade } from './EdgeFade.vue'
export { default as PixelLife } from './PixelLife.vue'

View File

@@ -14,4 +14,5 @@ export { default as PlanApproval } from './PlanApproval.vue'
export { default as CodeBlock } from './CodeBlock.vue'
export { default as AgentBadge } from './AgentBadge.vue'
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
export { default as VoiceMicButton } from './VoiceMicButton.vue'
export { AquaticBackground } from './aquaticBackground'

View File

@@ -19,9 +19,23 @@ import type {
ProgressEntry,
ContentBlock,
ToolUseBlock,
ToolResultBlock
ToolResultBlock,
TerminalSlot
} from '@/types/transcript-debug'
// Server registry entry shape (returned by GET /terminal-registry)
interface ServerRegistryEntry {
ephemeralSessionId: string
transcriptSessionId: string
agent: string
label: string
command: string
createdAt: string
alive: boolean
clients: number
bufferSize: number
}
export function useTranscriptDebug() {
// ── Persistence ──
const STORAGE_KEY = 'transcript-debug-selection'
@@ -59,7 +73,7 @@ export function useTranscriptDebug() {
const error = ref<string | null>(null)
const isRealtime = ref(false)
// ── Ephemeral terminal ──
// ── Terminal registry (server-backed) ──
const AGENT_CMD: Record<AgentName, string> = {
ejecutor: 'ejecutor',
@@ -67,29 +81,251 @@ export function useTranscriptDebug() {
claude: 'claude'
}
const ephemeral = shallowRef<EphemeralTerminal | null>(null)
// Server registry data (source of truth for all clients)
const serverRegistry = ref<ServerRegistryEntry[]>([])
// Local terminal instances (this client's active WS connections)
const localTerminals = new Map<string, EphemeralTerminal>()
// Which terminal is currently active for THIS client
const activeTerminalSessionId = ref<string | null>(null)
// Computed: current active terminal
const ephemeral = computed<EphemeralTerminal | null>(() => {
if (!activeTerminalSessionId.value) return null
return localTerminals.get(activeTerminalSessionId.value) ?? null
})
const terminalReady = computed(() => ephemeral.value?.state.value === 'running')
async function disposeTerminal() {
if (ephemeral.value) {
await ephemeral.value.dispose()
ephemeral.value = null
}
}
// Computed: terminal list for AgentBadge dropdown (from server registry)
const openTerminals = computed<TerminalSlot[]>(() =>
serverRegistry.value.map(entry => ({
sessionId: entry.transcriptSessionId,
ephemeralSessionId: entry.ephemeralSessionId,
agent: entry.agent as AgentName,
label: entry.label,
command: entry.command,
active: entry.transcriptSessionId === activeTerminalSessionId.value,
alive: entry.alive,
clients: entry.clients
}))
)
const awaitingNewSession = ref(false)
// ── Server registry HTTP helpers ──
async function fetchTerminalRegistry() {
try {
const res = await fetch(terminalApiUrl('/terminal-registry'))
if (!res.ok) return
const data = await res.json()
serverRegistry.value = data.registry ?? []
} catch { /* best effort */ }
}
async function registerTerminalOnServer(
ephemeralSessionId: string,
transcriptSessionId: string,
agent: AgentName,
label: string,
command: string
) {
try {
await fetch(terminalApiUrl('/register-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ephemeralSessionId,
transcriptSessionId,
agent,
label,
command,
createdAt: new Date().toISOString()
})
})
// Server broadcasts change → other clients pick it up
await fetchTerminalRegistry()
} catch { /* best effort */ }
}
async function updateTerminalOnServer(
ephemeralSessionId: string,
updates: { transcriptSessionId?: string; label?: string }
) {
try {
await fetch(terminalApiUrl('/update-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId, ...updates })
})
await fetchTerminalRegistry()
} catch { /* best effort */ }
}
async function unregisterTerminalOnServer(ephemeralSessionId: string) {
try {
await fetch(terminalApiUrl('/unregister-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId })
})
await fetchTerminalRegistry()
} catch { /* best effort */ }
}
// ── Registry polling for multi-client sync ──
let registryPollTimer: ReturnType<typeof setInterval> | null = null
function startRegistryPolling() {
if (registryPollTimer) return
registryPollTimer = setInterval(fetchTerminalRegistry, 5000)
}
function stopRegistryPolling() {
if (registryPollTimer) {
clearInterval(registryPollTimer)
registryPollTimer = null
}
}
// ── Terminal lifecycle ──
function getSessionLabel(sessionId: string): string {
const session = sessions.value.find(s => s.id === sessionId)
if (session?.firstUserMessage) {
const msg = session.firstUserMessage.trim()
return msg.length > 30 ? msg.slice(0, 30) + '...' : msg
}
return sessionId.slice(0, 12) + '...'
}
function startTerminal(sessionId?: string) {
const key = sessionId || '__new__'
const cmd = sessionId
? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"`
: AGENT_CMD[selectedAgent.value]
const term = useEphemeralTerminal(cmd)
ephemeral.value = term
localTerminals.set(key, term)
activeTerminalSessionId.value = key
term.start()
// Register on server
registerTerminalOnServer(
term.ephemeralSessionId,
key,
selectedAgent.value,
sessionId ? getSessionLabel(sessionId) : 'New session',
cmd
)
}
function parkCurrentTerminal() {
const currentId = activeTerminalSessionId.value
if (!currentId) return
const term = localTerminals.get(currentId)
if (term) {
term.park()
localTerminals.delete(currentId)
}
activeTerminalSessionId.value = null
}
function connectToTerminal(transcriptSessionId: string) {
// Find the server registry entry
const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId
)
if (!entry) return
// Create local EphemeralTerminal reusing the existing PTY session
const term = useEphemeralTerminal(entry.command, entry.ephemeralSessionId)
localTerminals.set(transcriptSessionId, term)
activeTerminalSessionId.value = transcriptSessionId
term.start()
}
async function closeTerminal(transcriptSessionId: string) {
// Find the ephemeralSessionId from registry
const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId
)
const ephSid = entry?.ephemeralSessionId
// Dispose local terminal if we have one
const term = localTerminals.get(transcriptSessionId)
if (term) {
await term.dispose()
localTerminals.delete(transcriptSessionId)
} else if (ephSid) {
// No local terminal — kill the PTY directly on server
try {
await fetch(terminalApiUrl('/kill-session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: ephSid })
})
} catch { /* best effort */ }
}
// Unregister if not auto-removed by kill
if (ephSid) {
await unregisterTerminalOnServer(ephSid)
}
await fetchTerminalRegistry()
// If it was active, switch to another or clear
if (activeTerminalSessionId.value === transcriptSessionId) {
activeTerminalSessionId.value = null
const remaining = openTerminals.value.filter(t => t.sessionId !== transcriptSessionId)
if (remaining.length > 0) {
await switchToTerminal(remaining[remaining.length - 1].sessionId)
}
}
}
async function switchToTerminal(transcriptSessionId: string) {
if (transcriptSessionId === activeTerminalSessionId.value) return
// Park current
parkCurrentTerminal()
// Find the entry — might be from another agent
const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId
)
if (entry && entry.agent !== selectedAgent.value) {
// Switch agent context without disposing terminals
selectedAgent.value = entry.agent as AgentName
await fetchSessions()
}
// Load the target session's transcript
selectedSessionId.value = transcriptSessionId
saveState()
await fetchSessionContent(transcriptSessionId)
// Connect to the terminal
connectToTerminal(transcriptSessionId)
}
async function disposeAllLocalTerminals() {
for (const [, term] of localTerminals) {
await term.dispose()
}
localTerminals.clear()
activeTerminalSessionId.value = null
}
async function createNewSession() {
await disposeTerminal()
parkCurrentTerminal()
selectedSessionId.value = null
rawContent.value = ''
@@ -158,9 +394,30 @@ export function useTranscriptDebug() {
// Refresh session list (new sessions or size changes)
await fetchSessions()
// New session just appeared — lock onto it without restarting terminal
// Also refresh terminal registry (other clients may have changed things)
await fetchTerminalRegistry()
// New session just appeared — lock onto it, re-key from __new__
if (awaitingNewSession.value) {
awaitingNewSession.value = false
// Re-key the __new__ local terminal
const newTerm = localTerminals.get('__new__')
if (newTerm) {
localTerminals.delete('__new__')
localTerminals.set(changedSessionId, newTerm)
// Update server registry with real sessionId
await updateTerminalOnServer(newTerm.ephemeralSessionId, {
transcriptSessionId: changedSessionId,
label: getSessionLabel(changedSessionId)
})
if (activeTerminalSessionId.value === '__new__') {
activeTerminalSessionId.value = changedSessionId
}
}
selectedSessionId.value = changedSessionId
saveState()
await fetchSessionContent(changedSessionId)
@@ -174,6 +431,15 @@ export function useTranscriptDebug() {
// No session selected yet — auto-select the newest
await selectSession(sessions.value[0].id)
}
// Update label for any terminal in the registry matching this session
const entry = serverRegistry.value.find(e => e.transcriptSessionId === changedSessionId)
if (entry) {
const newLabel = getSessionLabel(changedSessionId)
if (entry.label !== newLabel) {
updateTerminalOnServer(entry.ephemeralSessionId, { label: newLabel })
}
}
}
function handleRealtimeDone(changedSessionId: string) {
@@ -199,17 +465,13 @@ export function useTranscriptDebug() {
m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText)
)
if (found) {
// Real message arrived, drop the optimistic one
optimisticMessage.value = null
} else {
// Not yet in JSONL, keep showing it at the end
parsed.messages.push(optimisticMessage.value)
}
}
// Detect turn completion from JSONL content:
// If we're processing, the optimistic was replaced (real user entry exists),
// and the last substantive message is an assistant response → turn is done
// Detect turn completion from JSONL content
if (processing.value && !optimisticMessage.value) {
const lastSubstantive = [...parsed.messages]
.reverse()
@@ -225,14 +487,13 @@ export function useTranscriptDebug() {
}
}
// Kill terminal on page close/refresh
// Park local terminals on page close (don't kill — they persist for other clients)
function onBeforeUnload() {
if (ephemeral.value) {
const sessionId = ephemeral.value.ephemeralSessionId
navigator.sendBeacon(
terminalApiUrl('/kill-session'),
JSON.stringify({ sessionId })
)
for (const [, term] of localTerminals) {
// Just park: close WS without killing server PTY
try {
term.park()
} catch { /* best effort */ }
}
}
@@ -242,12 +503,19 @@ export function useTranscriptDebug() {
// Auto-cleanup on unmount
onUnmounted(() => {
disposeTerminal()
// Park local terminals (don't kill server PTYs — they're global)
for (const [, term] of localTerminals) {
try { term.park() } catch {}
}
localTerminals.clear()
activeTerminalSessionId.value = null
stopRegistryPolling()
disconnectRealtime()
window.removeEventListener('beforeunload', onBeforeUnload)
})
// ── Send prompt (spawns independent claude process) ──
// ── Send prompt ──
const sending = ref(false)
const processing = ref(false)
@@ -265,8 +533,6 @@ export function useTranscriptDebug() {
ephemeral.value.sendInput(text)
processing.value = true
// Optimistic: show user message immediately in chat
// Stays until the real user entry appears in the JSONL
optimisticMessage.value = {
kind: 'user',
uuid: `optimistic-${Date.now()}`,
@@ -296,7 +562,10 @@ export function useTranscriptDebug() {
async function fetchSessionContent(sessionId: string): Promise<boolean> {
try {
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${selectedAgent.value}`)
// Determine agent from registry if different from selected
const entry = serverRegistry.value.find(e => e.transcriptSessionId === sessionId)
const agent = entry?.agent || selectedAgent.value
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
rawContent.value = await res.text()
conversation.value = parseJsonl(rawContent.value, sessionId)
@@ -309,6 +578,8 @@ export function useTranscriptDebug() {
async function init() {
await fetchSessions()
await fetchTerminalRegistry()
startRegistryPolling()
let targetSession = selectedSessionId.value
@@ -326,7 +597,17 @@ export function useTranscriptDebug() {
selectedSessionId.value = targetSession
saveState()
await fetchSessionContent(targetSession)
startTerminal(targetSession)
// Check if there's already a terminal for this session in the registry
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === targetSession
)
if (existing && existing.alive) {
// Connect to existing terminal (may have been created by another client)
connectToTerminal(targetSession)
} else {
startTerminal(targetSession)
}
} else {
selectedSessionId.value = null
saveState()
@@ -339,25 +620,35 @@ export function useTranscriptDebug() {
async function switchAgent(agent: AgentName) {
if (agent === selectedAgent.value) return
await disposeTerminal()
// Park local terminals (don't kill — they're global)
parkCurrentTerminal()
selectedAgent.value = agent
error.value = null
loading.value = true
transitioning.value = true
// Wait for fade-out
await new Promise(r => setTimeout(r, 150))
rawContent.value = ''
conversation.value = null
await fetchSessions()
await fetchTerminalRegistry()
if (sessions.value.length > 0) {
selectedSessionId.value = sessions.value[0].id
await fetchSessionContent(sessions.value[0].id)
startTerminal(sessions.value[0].id)
// Check if there's already a terminal for this session
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === sessions.value[0].id && e.alive
)
if (existing) {
connectToTerminal(sessions.value[0].id)
} else {
startTerminal(sessions.value[0].id)
}
} else {
selectedSessionId.value = null
}
@@ -370,13 +661,12 @@ export function useTranscriptDebug() {
async function selectSession(sessionId: string) {
if (sessionId === selectedSessionId.value) return
await disposeTerminal()
parkCurrentTerminal()
error.value = null
loading.value = true
transitioning.value = true
// Wait for fade-out
await new Promise(r => setTimeout(r, 150))
selectedSessionId.value = sessionId
@@ -386,7 +676,15 @@ export function useTranscriptDebug() {
transitioning.value = false
loading.value = false
startTerminal(sessionId)
// Check if there's already a terminal for this session in the registry
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === sessionId && e.alive
)
if (existing) {
connectToTerminal(sessionId)
} else {
startTerminal(sessionId)
}
}
// ── JSONL Parser ──
@@ -427,19 +725,11 @@ export function useTranscriptDebug() {
}
}
// Build conversation messages
const messages: ConversationMessage[] = []
// Group assistant entries by message.id (streaming chunks)
const assistantGroups = new Map<string, AssistantEntry[]>()
// Collect tool results from user entries
const toolResultMap = new Map<string, ParsedToolResult>()
// Collect progress events by toolUseID
const progressByTool = new Map<string, ParsedProgressEvent[]>()
// Helper to build a ParsedProgressEvent from a raw ProgressEntry
function buildProgressEvent(pe: ProgressEntry): ParsedProgressEvent {
return {
uuid: pe.uuid,
@@ -517,7 +807,6 @@ export function useTranscriptDebug() {
continue
}
// Flush progress batch before next non-progress entry
if (currentProgressBatch.length > 0) {
messages.push({
kind: 'progress',
@@ -566,7 +855,6 @@ export function useTranscriptDebug() {
const group = assistantGroups.get(msgId)
if (!group) continue
// Only process first entry in group (we merge all chunks)
if (group[0].uuid !== ae.uuid) continue
const textBlocks: string[] = []
@@ -629,7 +917,6 @@ export function useTranscriptDebug() {
}
}
// Flush remaining progress
if (currentProgressBatch.length > 0) {
messages.push({
kind: 'progress',
@@ -675,11 +962,15 @@ export function useTranscriptDebug() {
ephemeral,
terminalReady,
awaitingNewSession,
openTerminals,
activeTerminalSessionId,
init,
fetchSessions,
switchAgent,
selectSession,
createNewSession,
switchToTerminal,
closeTerminal,
connectRealtime,
disconnectRealtime,
sendPrompt

View File

@@ -1,9 +1,9 @@
/**
* useEphemeralTerminal
*
* Lightweight composable for ephemeral terminal sessions.
* Creates a temporary PTY via WebSocket, runs a command, and
* cleans up completely when disposed (no persistent session).
* Composable for terminal sessions that can be parked (WS closed,
* renderer disposed) and later reconnected to the same server PTY
* via buffer replay.
*
* Used by ResumeTerminalButton to open `<agent> --resume <sessionId>`.
*/
@@ -32,16 +32,21 @@ export interface EphemeralTerminal {
/** Full cleanup (stop + dispose renderer) */
dispose: () => Promise<void>
/** Park: close WS + dispose renderer, but keep server PTY alive for reconnection */
park: () => void
}
export function useEphemeralTerminal(
command: string
command: string,
existingSessionId?: string
): EphemeralTerminal {
const containerRef = ref<HTMLElement | null>(null)
const connected = ref(false)
const state = ref<EphemeralState>('off')
const ephemeralSessionId = `resume-${Date.now()}`
const ephemeralSessionId = existingSessionId || `resume-${Date.now()}`
const isReconnect = !!existingSessionId
let socket: WebSocket | null = null
@@ -104,13 +109,21 @@ export function useEphemeralTerminal(
switch (msg.type) {
case 'connected':
state.value = 'shell-ready'
// Wait for shell prompt to render, then send the command
setTimeout(() => {
if (isReconnect) {
// Reconnecting to existing PTY — request buffer replay
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: command + '\r' }))
socket.send(JSON.stringify({ type: 'request-replay', tailOnly: false }))
}
state.value = 'running'
}, 500)
} else {
// First time — wait for shell prompt, then send command
setTimeout(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: command + '\r' }))
}
state.value = 'running'
}, 500)
}
break
case 'replay':
renderer.handleReplay(msg.data || '')
@@ -188,6 +201,18 @@ export function useEphemeralTerminal(
renderer.dispose()
}
function park() {
// Close WS without killing server PTY — it stays alive for reconnection
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
connected.value = false
renderer.dispose()
state.value = 'off'
}
return {
state,
connected,
@@ -197,6 +222,7 @@ export function useEphemeralTerminal(
start,
sendInput,
stop,
dispose
dispose,
park
}
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
@@ -27,6 +28,19 @@ const {
sendPrompt
} = useTranscriptDebug()
const voice = useVoiceInput({ language: 'es-419' })
const {
isRecording: voiceRecording,
transcript: voiceTranscript,
interimTranscript: voiceInterim,
voiceMode,
whisperStatus,
audioDevices,
selectedDeviceId,
lastAudioUrl,
isPlayingAudio,
} = voice
const agents: { id: AgentName; label: string }[] = [
{ id: 'ejecutor', label: 'Ejecutor' },
{ id: 'nucleo000', label: 'nucleo000' },
@@ -34,6 +48,7 @@ const agents: { id: AgentName; label: string }[] = [
]
function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message)
}
@@ -41,12 +56,14 @@ function handleCreateSession() {
createNewSession()
}
onMounted(() => {
onMounted(async () => {
init()
await voice.init()
})
onUnmounted(() => {
disconnectRealtime()
voice.cleanup()
})
</script>
@@ -119,8 +136,21 @@ onUnmounted(() => {
:terminal-ready="terminalReady"
:terminal="ephemeral"
:selected-agent="selectedAgent"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:audio-devices="audioDevices"
:selected-device-id="selectedDeviceId"
:is-recording="voiceRecording"
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
@send="handleSend"
@create-session="handleCreateSession"
@start-recording="voice.startRecording()"
@stop-recording="voice.stopRecording()"
@set-voice-mode="voice.setMode($event)"
@select-microphone="voice.selectMicrophone($event)"
@play-last-audio="voice.playLastAudio()"
/>
</div>
</div>

View File

@@ -18,8 +18,6 @@
html, body {
height: 100%;
min-height: 100vh;
min-height: 100dvh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
@@ -28,10 +26,13 @@ html, body {
overflow: hidden;
}
body {
position: fixed;
width: 100%;
}
#app {
height: 100%;
min-height: 100vh;
min-height: 100dvh;
}
/* Scrollbar styling */

View File

@@ -216,6 +216,19 @@ export interface ParsedSystemMessage {
subtype?: string
}
// ── Terminal slot (persistent terminal registry) ──
export interface TerminalSlot {
sessionId: string // Claude transcript session ID (transcriptSessionId)
ephemeralSessionId: string // PTY session ID on server (for reconnection)
agent: AgentName
label: string // firstUserMessage (truncated) or "New session"
command: string // The command used to start
active: boolean // Is this the currently displayed terminal (for this client)?
alive: boolean // PTY exists on server
clients: number // Number of connected WS clients
}
// ── Raw entry union ──
export type TranscriptEntry =

View File

@@ -33,6 +33,47 @@ const sessions = new Map<string, TerminalSession>()
// Map WebSocket to sessionId
const wsToSession = new Map<any, string>()
// ── Global terminal registry ──
// Tracks metadata about transcript-debug terminals so all clients can see/connect to them
interface TerminalRegistryEntry {
ephemeralSessionId: string // PTY session ID on this server
transcriptSessionId: string // Claude transcript session being resumed (or '__new__')
agent: string // ejecutor | nucleo000 | claude
label: string // First user message or short description
command: string // Full command that was run
createdAt: string // ISO timestamp
}
const terminalRegistry = new Map<string, TerminalRegistryEntry>() // keyed by ephemeralSessionId
function getRegistrySnapshot() {
return Array.from(terminalRegistry.values()).map(entry => {
const ptySession = sessions.get(entry.ephemeralSessionId)
return {
...entry,
alive: !!ptySession,
clients: ptySession?.clients.size ?? 0,
bufferSize: ptySession?.outputBuffer.length ?? 0
}
})
}
function broadcastRegistryChange() {
const message = JSON.stringify({
type: 'terminal-registry-change',
registry: getRegistrySnapshot(),
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] Registry broadcast → ${clientCount} clients (${terminalRegistry.size} entries)`)
}
function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession {
let session = sessions.get(sessionId)
@@ -83,6 +124,12 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
}
sessions.delete(sessionId)
// Auto-remove from terminal registry
if (terminalRegistry.has(sessionId)) {
terminalRegistry.delete(sessionId)
broadcastRegistryChange()
}
// Mark agent as not running if this is an agent session
if (sessionId.startsWith('agent-')) {
const agentId = sessionId.replace('agent-', '')
@@ -122,6 +169,13 @@ export function killSession(sessionId: string): boolean {
}
sessions.delete(sessionId)
// Auto-remove from terminal registry
if (terminalRegistry.has(sessionId)) {
terminalRegistry.delete(sessionId)
broadcastRegistryChange()
}
return true
}
@@ -301,6 +355,67 @@ export function startTerminalServer() {
}
}
// ── Terminal Registry endpoints ──
// List all registered terminals (global, for all clients)
if (url.pathname === '/terminal-registry' && req.method === 'GET') {
return Response.json({ registry: getRegistrySnapshot() }, { headers: corsHeaders })
}
// Register a new terminal
if (url.pathname === '/register-terminal' && req.method === 'POST') {
try {
const body = await req.json() as TerminalRegistryEntry
if (!body.ephemeralSessionId) {
return Response.json({ error: 'ephemeralSessionId required' }, { status: 400, headers: corsHeaders })
}
terminalRegistry.set(body.ephemeralSessionId, {
ephemeralSessionId: body.ephemeralSessionId,
transcriptSessionId: body.transcriptSessionId || '',
agent: body.agent || '',
label: body.label || '',
command: body.command || '',
createdAt: body.createdAt || new Date().toISOString()
})
console.log(`[Terminal] Registered terminal: ${body.ephemeralSessionId}${body.transcriptSessionId} (${body.agent})`)
broadcastRegistryChange()
return Response.json({ success: true }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
// Update a registered terminal (e.g. re-key transcriptSessionId, update label)
if (url.pathname === '/update-terminal' && req.method === 'POST') {
try {
const body = await req.json() as Partial<TerminalRegistryEntry> & { ephemeralSessionId: string }
const entry = terminalRegistry.get(body.ephemeralSessionId)
if (!entry) {
return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders })
}
if (body.transcriptSessionId !== undefined) entry.transcriptSessionId = body.transcriptSessionId
if (body.label !== undefined) entry.label = body.label
if (body.agent !== undefined) entry.agent = body.agent
if (body.command !== undefined) entry.command = body.command
broadcastRegistryChange()
return Response.json({ success: true }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
// Unregister a terminal (does NOT kill the PTY — use /kill-session for that)
if (url.pathname === '/unregister-terminal' && req.method === 'POST') {
try {
const body = await req.json() as { ephemeralSessionId: string }
const deleted = terminalRegistry.delete(body.ephemeralSessionId)
if (deleted) broadcastRegistryChange()
return Response.json({ success: true, deleted }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
// Check if this is a WebSocket upgrade request
const upgradeHeader = req.headers.get('upgrade')
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)

View File

@@ -1,248 +0,0 @@
## ============================================
## Agent UI - Traefik Dynamic Configuration
## ============================================
##
## INSTALACIÓN:
## 1. Copia este archivo a tu directorio de config dinámica de Traefik
## Ejemplo: /etc/traefik/dynamic/agent-ui.yml
##
## 2. Asegúrate de tener el file provider habilitado en traefik.yml:
## providers:
## file:
## directory: /etc/traefik/dynamic
## watch: true
##
## 3. Asegúrate de tener el certResolver configurado (letsencrypt)
##
## ARQUITECTURA:
## ┌─────────────────────────────────────────────────────┐
## │ https://z590.nucleoriofrio.com │
## │ / → Frontend (4100) │
## │ /api/* → API Server (4101) │
## │ /ws/terminal→ Terminal WebSocket (4103) │
## │ /ws/status → Claude Status WebSocket (4103) │
## │ /ws/mcp → WebMCP WebSocket (4102) │
## │ /ws/whisper → Whisper WebSocket (4104) │
## └─────────────────────────────────────────────────────┘
##
http:
## ============================================
## MIDDLEWARES
## ============================================
middlewares:
## ----------------------------------------
## IP Whitelist (DESCOMENTEAR PARA ACTIVAR)
## ----------------------------------------
## Para activar: descomenta este bloque y agrega
## "agentui-ipwhitelist" a la lista de middlewares
## de cada router.
##
# agentui-ipwhitelist:
# ipWhiteList:
# sourceRange:
# - "192.168.87.0/24" # Red local del servidor
# - "192.168.1.0/24" # Otra red local
# - "10.0.0.0/8" # Redes internas
# - "YOUR_PUBLIC_IP/32" # Tu IP pública específica
# # Si hay proxies intermedios, usa ipStrategy:
# # ipStrategy:
# # depth: 1
## ----------------------------------------
## Strip Prefix para WebSockets
## ----------------------------------------
## Traefik recibe /ws/terminal pero el backend espera /
strip-ws-terminal:
stripPrefix:
prefixes:
- "/ws/terminal"
strip-ws-status:
stripPrefix:
prefixes:
- "/ws/status"
strip-ws-mcp:
stripPrefix:
prefixes:
- "/ws/mcp"
strip-ws-whisper:
stripPrefix:
prefixes:
- "/ws/whisper"
## ----------------------------------------
## Headers de seguridad
## ----------------------------------------
agentui-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
# Headers de seguridad opcionales:
# stsSeconds: 31536000
# stsIncludeSubdomains: true
# contentTypeNosniff: true
# browserXssFilter: true
## ============================================
## ROUTERS
## ============================================
routers:
## ----------------------------------------
## Frontend (/) - Vue App
## ----------------------------------------
## Sirve la aplicación Vue, assets, PWA manifest, etc.
## Excluye /api y /ws para que los otros routers los manejen.
##
agentui-frontend:
rule: "Host(`z590.nucleoriofrio.com`) && !PathPrefix(`/api`) && !PathPrefix(`/ws`)"
entryPoints:
- websecure
service: agentui-frontend
middlewares:
- agentui-headers
# - agentui-ipwhitelist # Descomentar para activar
tls:
certResolver: letsencrypt
priority: 1
## ----------------------------------------
## API Backend (/api/*) - REST API
## ----------------------------------------
## Maneja todas las llamadas REST:
## - /api/health - Health check
## - /api/themes/* - Sistema de temas
## - /api/components/* - Componentes Vue guardados
## - /api/database/* - Explorador de base de datos
## - /api/claude-status - Status de Claude (desde hooks)
## - /api/whisper/* - Control de Whisper
## - etc.
##
agentui-api:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/api`)"
entryPoints:
- websecure
service: agentui-api
middlewares:
- agentui-headers
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 10
## ----------------------------------------
## Terminal WebSocket (/ws/terminal)
## ----------------------------------------
## Conexión WebSocket para xterm.js
## Permite ejecutar comandos en el servidor.
##
agentui-ws-terminal:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/terminal`)"
entryPoints:
- websecure
service: agentui-terminal
middlewares:
- strip-ws-terminal
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ----------------------------------------
## Claude Status WebSocket (/ws/status)
## ----------------------------------------
## Recibe actualizaciones de estado de Claude/Nucleo
## para las animaciones del FAB.
## Usa el mismo backend que terminal (4103).
##
agentui-ws-status:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/status`)"
entryPoints:
- websecure
service: agentui-terminal
middlewares:
- strip-ws-status
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ----------------------------------------
## WebMCP WebSocket (/ws/mcp)
## ----------------------------------------
## Bridge entre Claude Code MCP y el browser.
## Permite que Claude ejecute herramientas en la UI:
## - render_vue_component
## - navigate_to
## - set_theme_variable
## - etc.
##
agentui-ws-mcp:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/mcp`)"
entryPoints:
- websecure
service: agentui-mcp
middlewares:
- strip-ws-mcp
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ----------------------------------------
## Whisper WebSocket (/ws/whisper)
## ----------------------------------------
## Transcripción de voz con Whisper.
## Opcional - solo si usas la función de voz.
##
agentui-ws-whisper:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/whisper`)"
entryPoints:
- websecure
service: agentui-whisper
middlewares:
- strip-ws-whisper
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ============================================
## SERVICES (Backends)
## ============================================
## Cambia 192.168.87.135 por la IP de tu servidor
## si es diferente.
##
services:
## Frontend - Vite dev server o build estático
agentui-frontend:
loadBalancer:
servers:
- url: "http://192.168.87.135:4100"
## API Server - Bun HTTP
agentui-api:
loadBalancer:
servers:
- url: "http://192.168.87.135:4101"
## Terminal WebSocket Server
## También maneja Claude status broadcast
agentui-terminal:
loadBalancer:
servers:
- url: "http://192.168.87.135:4103"
## WebMCP WebSocket Server
agentui-mcp:
loadBalancer:
servers:
- url: "http://192.168.87.135:4102"
## Whisper WebSocket Server (opcional)
agentui-whisper:
loadBalancer:
servers:
- url: "http://192.168.87.135:4104"