feat: Introduce Nucleo as the main AI agent identity

- Add Nucleo atom logo with animated orbiting electrons
- Redesign FAB with glassmorphism effect and purple gradient
- Add connection indicator (green dot) when terminal is open
- Update FloatingTerminal header with Nucleo branding
- Add PermissionRequest hook support with red alert animation
- Document Nucleo in README with visual states table
- Create official Nucleo logo SVG in docs/
This commit is contained in:
2026-02-14 02:56:50 -06:00
parent d9e2548fb8
commit 47f5524416
6 changed files with 368 additions and 52 deletions

View File

@@ -25,17 +25,18 @@ const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const canvasStore = useCanvasStore()
// Claude status state (for FAB animations)
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'thinking'
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
const claudeStatus = ref<ClaudeStatus>('idle')
const claudeTool = ref<string | null>(null)
const isProcessing = ref(false) // Main processing state (UserPromptSubmit → Stop)
const isReading = ref(false) // Reading files
const isWriting = ref(false) // Writing files
const hasSubagent = ref(false) // Subagent active
const showSessionStart = ref(false) // Session start animation (3s)
const showNotification = ref(false) // Notification pulse (2s)
const showToolFlash = ref(false) // Tool use flash (500ms)
const isProcessing = ref(false) // Main processing state (UserPromptSubmit → Stop)
const isReading = ref(false) // Reading files
const isWriting = ref(false) // Writing files
const hasSubagent = ref(false) // Subagent active
const awaitingPermission = ref(false) // Waiting for user permission (highest priority)
const showSessionStart = ref(false) // Session start animation (3s)
const showNotification = ref(false) // Notification pulse (2s)
const showToolFlash = ref(false) // Tool use flash (500ms)
let statusWs: WebSocket | null = null
let statusReconnectTimeout: number | null = null
@@ -78,9 +79,14 @@ function connectStatusWs() {
isProcessing.value = false
isReading.value = false
isWriting.value = false
awaitingPermission.value = false
if (processingTimeout) clearTimeout(processingTimeout)
break
case 'permissionRequest':
awaitingPermission.value = true
break
case 'reading':
isReading.value = true
triggerToolFlash()
@@ -98,6 +104,7 @@ function connectStatusWs() {
case 'toolDone':
isReading.value = false
isWriting.value = false
awaitingPermission.value = false
break
case 'sessionStart':
@@ -278,12 +285,13 @@ watch(() => route.name, (newPage) => {
reading: isReading,
writing: isWriting,
subagent: hasSubagent,
permission: awaitingPermission,
'session-start': showSessionStart,
notification: showNotification,
'tool-flash': showToolFlash
}"
@click="showTerminal = !showTerminal"
:title="isProcessing ? `Claude: ${claudeTool || 'processing'}` : 'Toggle Terminal'"
:title="awaitingPermission ? `Permiso requerido: ${claudeTool || 'herramienta'}` : isProcessing ? `Claude: ${claudeTool || 'processing'}` : 'Toggle Terminal'"
>
<!-- Subagent orbital ring -->
<svg v-if="hasSubagent" class="orbital-ring" viewBox="0 0 100 100">
@@ -297,8 +305,15 @@ watch(() => route.name, (newPage) => {
<span></span>
</div>
<!-- Permission request icon (alert/hand) - highest priority -->
<svg v-if="awaitingPermission" class="permission-icon" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<!-- Processing animation (three dots) -->
<div v-if="isProcessing && !isReading && !isWriting" class="thinking-dots">
<div v-else-if="isProcessing && !isReading && !isWriting" class="thinking-dots">
<span></span>
<span></span>
<span></span>
@@ -320,14 +335,26 @@ watch(() => route.name, (newPage) => {
<!-- Normal icons -->
<template v-else>
<svg v-if="!showTerminal" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
<!-- Terminal closed: Nucleo atom icon -->
<svg v-if="!showTerminal" class="nucleo-icon" width="28" height="28" viewBox="0 0 24 24" fill="none">
<!-- Core nucleus -->
<circle cx="12" cy="12" r="3.5" fill="white" opacity="0.95"/>
<!-- Orbital rings -->
<ellipse cx="12" cy="12" rx="8" ry="3.5" stroke="white" stroke-width="1.3" fill="none" opacity="0.7" transform="rotate(-30 12 12)"/>
<ellipse cx="12" cy="12" rx="8" ry="3.5" stroke="white" stroke-width="1.3" fill="none" opacity="0.5" transform="rotate(30 12 12)"/>
<ellipse cx="12" cy="12" rx="8" ry="3.5" stroke="white" stroke-width="1.3" fill="none" opacity="0.6" transform="rotate(90 12 12)"/>
<!-- Electrons -->
<circle cx="12" cy="4" r="1.8" fill="white" opacity="0.9"/>
<circle cx="19" cy="14" r="1.8" fill="white" opacity="0.7"/>
<circle cx="5" cy="14" r="1.8" fill="white" opacity="0.8"/>
</svg>
<!-- Terminal open: Minimize chevron with connection indicator -->
<template v-else>
<span class="connection-dot"></span>
<svg class="minimize-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</template>
</template>
</button>
@@ -419,35 +446,102 @@ watch(() => route.name, (newPage) => {
position: fixed;
bottom: 20px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
width: 58px;
height: 58px;
border-radius: 18px;
background: linear-gradient(145deg, #7c3aed 0%, #6366f1 50%, #8b5cf6 100%);
color: white;
border: none;
border: 2px solid rgba(255, 255, 255, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 15px rgba(124, 58, 237, 0.4),
0 8px 30px rgba(99, 102, 241, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 9998;
overflow: visible;
backdrop-filter: blur(10px);
}
.terminal-fab::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 21px;
background: linear-gradient(145deg, rgba(139, 92, 246, 0.5), rgba(99, 102, 241, 0.2));
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.terminal-fab:hover {
transform: scale(1.05);
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.5);
transform: translateY(-3px) scale(1.05);
box-shadow:
0 8px 25px rgba(124, 58, 237, 0.5),
0 15px 40px rgba(99, 102, 241, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.terminal-fab:hover::before {
opacity: 1;
}
/* Nucleo atom icon animation */
.nucleo-icon {
animation: nucleo-orbit 8s linear infinite;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.6));
}
.nucleo-icon ellipse {
transform-origin: center;
}
/* Terminal active - Connected state */
.terminal-fab.active {
background: #ef4444;
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
transform: rotate(90deg);
background: linear-gradient(145deg, #6366f1 0%, #4f46e5 50%, #7c3aed 100%);
border-color: rgba(16, 185, 129, 0.5);
box-shadow:
0 4px 15px rgba(99, 102, 241, 0.4),
0 8px 30px rgba(79, 70, 229, 0.3),
0 0 20px rgba(16, 185, 129, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transform: none;
}
.terminal-fab.active:hover {
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
box-shadow:
0 6px 20px rgba(99, 102, 241, 0.5),
0 12px 35px rgba(79, 70, 229, 0.35),
0 0 25px rgba(16, 185, 129, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
/* Connection indicator dot */
.connection-dot {
position: absolute;
top: 8px;
right: 8px;
width: 10px;
height: 10px;
background: #10b981;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.8);
animation: connection-pulse 2s ease-in-out infinite;
}
/* Minimize icon */
.minimize-icon {
opacity: 0.9;
transition: transform 0.2s ease;
}
.terminal-fab.active:hover .minimize-icon {
transform: translateY(2px);
}
/* Processing state (UserPromptSubmit → Stop) - Orange pulsing */
@@ -492,6 +586,25 @@ watch(() => route.name, (newPage) => {
animation: notification-bounce 0.5s ease-in-out 4 !important;
}
/* Permission Request - HIGHEST PRIORITY - Red pulsing alert */
.terminal-fab.permission {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7) !important;
animation: permission-pulse 1s ease-in-out infinite !important;
transform: rotate(0deg) scale(1.1) !important;
z-index: 10000 !important;
}
.terminal-fab.permission:hover {
transform: scale(1.15) !important;
}
/* Permission icon animation */
.permission-icon {
animation: permission-shake 0.5s ease-in-out infinite;
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.8));
}
/* Tool flash - Quick white flash */
.terminal-fab.tool-flash::after {
content: '';
@@ -643,6 +756,48 @@ watch(() => route.name, (newPage) => {
}
}
@keyframes permission-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7), 0 8px 24px rgba(239, 68, 68, 0.5);
transform: scale(1.1);
}
50% {
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0), 0 8px 40px rgba(239, 68, 68, 0.8);
transform: scale(1.15);
}
}
@keyframes permission-shake {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
@keyframes nucleo-orbit {
0% {
transform: rotate(0deg);
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.6));
}
50% {
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.9));
}
100% {
transform: rotate(360deg);
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.6));
}
}
@keyframes connection-pulse {
0%, 100% {
box-shadow: 0 0 8px rgba(16, 185, 129, 0.8);
transform: scale(1);
}
50% {
box-shadow: 0 0 12px rgba(16, 185, 129, 1), 0 0 20px rgba(16, 185, 129, 0.4);
transform: scale(1.1);
}
}
/* Voice FAB */
.voice-fab {
position: fixed;
@@ -681,13 +836,20 @@ watch(() => route.name, (newPage) => {
.terminal-fab {
bottom: 16px;
right: 16px;
width: 52px;
height: 52px;
width: 54px;
height: 54px;
border-radius: 16px;
}
.terminal-fab.active:not(.processing):not(.reading):not(.writing) {
opacity: 0;
pointer-events: none;
.terminal-fab::before {
border-radius: 19px;
}
.connection-dot {
width: 8px;
height: 8px;
top: 6px;
right: 6px;
}
.orbital-ring {

View File

@@ -498,10 +498,40 @@ defineExpose({
<!-- Titlebar -->
<div class="titlebar" @mousedown="startDrag">
<div class="left">
<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>Terminal</span>
<!-- Nucleo Logo -->
<div class="nucleo-logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<!-- Core nucleus -->
<circle cx="12" cy="12" r="4" fill="url(#nucleoGradient)"/>
<!-- Orbital rings -->
<ellipse cx="12" cy="12" rx="9" ry="4" stroke="url(#orbitGradient)" stroke-width="1.2" fill="none" transform="rotate(-30 12 12)"/>
<ellipse cx="12" cy="12" rx="9" ry="4" stroke="url(#orbitGradient)" stroke-width="1.2" fill="none" transform="rotate(30 12 12)"/>
<ellipse cx="12" cy="12" rx="9" ry="4" stroke="url(#orbitGradient)" stroke-width="1.2" fill="none" transform="rotate(90 12 12)"/>
<!-- Electrons -->
<circle cx="12" cy="3" r="1.5" fill="#a78bfa">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="20" cy="14" r="1.5" fill="#818cf8">
<animateTransform attributeName="transform" type="rotate" from="120 12 12" to="480 12 12" dur="4s" repeatCount="indefinite"/>
</circle>
<circle cx="4" cy="14" r="1.5" fill="#c4b5fd">
<animateTransform attributeName="transform" type="rotate" from="240 12 12" to="600 12 12" dur="3.5s" repeatCount="indefinite"/>
</circle>
<!-- Gradients -->
<defs>
<radialGradient id="nucleoGradient" cx="50%" cy="30%" r="60%">
<stop offset="0%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#6366f1"/>
</radialGradient>
<linearGradient id="orbitGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818cf8" stop-opacity="0.8"/>
<stop offset="50%" stop-color="#a78bfa" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.8"/>
</linearGradient>
</defs>
</svg>
</div>
<span class="nucleo-name">Nucleo</span>
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
</div>
@@ -565,11 +595,28 @@ defineExpose({
.left {
display: flex;
align-items: center;
gap: 5px;
gap: 6px;
color: #222;
font: 500 10px/1 system-ui, sans-serif;
}
.nucleo-logo {
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 1px 2px rgba(99, 102, 241, 0.3));
}
.nucleo-name {
font-weight: 600;
font-size: 11px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5px;
}
.dot {
width: 5px; height: 5px;
border-radius: 50%;