From 47f55244161c76ba9340c8c8b48a0ad6862d1d4e Mon Sep 17 00:00:00 2001 From: josedario87 Date: Sat, 14 Feb 2026 02:56:50 -0600 Subject: [PATCH] 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/ --- README.md | 44 ++++ docs/nucleo-logo.svg | 61 +++++ frontend/src/App.vue | 232 ++++++++++++++++--- frontend/src/components/FloatingTerminal.vue | 57 ++++- server/routes/claude-status.ts | 24 +- server/services/terminal.ts | 2 +- 6 files changed, 368 insertions(+), 52 deletions(-) create mode 100644 docs/nucleo-logo.svg diff --git a/README.md b/README.md index 2dfeb53..172e6b8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,50 @@ Dynamic canvas interface for Claude Code interaction via MCP (Model Context Protocol). +--- + +## Nucleo + +

+ Nucleo +

+ +**Nucleo** is the main AI agent powering Agent UI. It serves as the bridge between Claude Code and your visual interface, providing real-time status feedback through an animated FAB (Floating Action Button). + +### Visual States + +Nucleo communicates its current state through distinct animations: + +| State | Color | Animation | Trigger | +|-------|-------|-----------|---------| +| **Idle** | Purple | Rotating atom | Default state | +| **Processing** | Orange | Pulsing dots | User prompt submitted | +| **Reading** | Cyan | Eye icon + scan | Reading files (Read/Glob/Grep) | +| **Writing** | Green | Pencil icon + pulse | Writing files (Edit/Write) | +| **Subagent** | Purple | Orbital ring | Task tool spawned | +| **Permission** | Red | Alert + shake | Awaiting user permission | +| **Session Start** | Green | Ripple waves | Session initialized | +| **Notification** | Yellow | Bounce | System notification | + +### Integration + +Nucleo's status is synchronized via Claude Code hooks: + +```json +{ + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "command": "... status: processing ..." }] }], + "PreToolUse": [{ "hooks": [{ "command": "... status: toolUse ..." }] }], + "PostToolUse": [{ "hooks": [{ "command": "... status: toolDone ..." }] }], + "Stop": [{ "hooks": [{ "command": "... status: idle ..." }] }] + } +} +``` + +The FAB receives these status updates via WebSocket and displays the corresponding animation, giving you real-time visibility into what Claude is doing. + +--- + ## Overview Agent UI provides a visual canvas where Claude Code can render dynamic Vue 3 components, HTML content, and interactive UIs in real-time. It bridges the gap between CLI-based AI assistance and rich visual interfaces. diff --git a/docs/nucleo-logo.svg b/docs/nucleo-logo.svg new file mode 100644 index 0000000..ac6884a --- /dev/null +++ b/docs/nucleo-logo.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4cda87d..8d7beae 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -25,17 +25,18 @@ const responseRef = ref | 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('idle') const claudeTool = ref(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'" > @@ -297,8 +305,15 @@ watch(() => route.name, (newPage) => { + + + + + + + -
+
@@ -320,14 +335,26 @@ watch(() => route.name, (newPage) => { @@ -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 { diff --git a/frontend/src/components/FloatingTerminal.vue b/frontend/src/components/FloatingTerminal.vue index b82d310..09d91bf 100644 --- a/frontend/src/components/FloatingTerminal.vue +++ b/frontend/src/components/FloatingTerminal.vue @@ -498,10 +498,40 @@ defineExpose({
- - - - Terminal + + + Nucleo connect
@@ -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%; diff --git a/server/routes/claude-status.ts b/server/routes/claude-status.ts index 0b307c5..04e4bd8 100644 --- a/server/routes/claude-status.ts +++ b/server/routes/claude-status.ts @@ -3,16 +3,17 @@ import { PORT_TERMINAL } from '../config' type ClaudeStatus = | 'idle' - | 'processing' // UserPromptSubmit - Claude is processing user input - | 'toolUse' // PreToolUse - Using a tool (generic) - | 'toolDone' // PostToolUse - Tool finished - | 'reading' // PreToolUse(Read/Glob/Grep) - Reading files - | 'writing' // PreToolUse(Edit/Write) - Writing files - | 'sessionStart' // SessionStart - Session just started - | 'subagentStart' // SubagentStart - Spawning subagent - | 'subagentStop' // SubagentStop - Subagent finished - | 'notification' // Notification - Claude sent notification - | 'thinking' // Legacy support + | 'processing' // UserPromptSubmit - Claude is processing user input + | 'toolUse' // PreToolUse - Using a tool (generic) + | 'toolDone' // PostToolUse - Tool finished + | 'reading' // PreToolUse(Read/Glob/Grep) - Reading files + | 'writing' // PreToolUse(Edit/Write) - Writing files + | 'sessionStart' // SessionStart - Session just started + | 'subagentStart' // SubagentStart - Spawning subagent + | 'subagentStop' // SubagentStop - Subagent finished + | 'notification' // Notification - Claude sent notification + | 'permissionRequest' // PermissionRequest - Waiting for user permission + | 'thinking' // Legacy support interface ClaudeStatusPayload { status: ClaudeStatus @@ -27,7 +28,8 @@ export async function handleClaudeStatus(req: Request): Promise const validStatuses: ClaudeStatus[] = [ 'idle', 'processing', 'toolUse', 'toolDone', 'reading', 'writing', - 'sessionStart', 'subagentStart', 'subagentStop', 'notification', 'thinking' + 'sessionStart', 'subagentStart', 'subagentStop', 'notification', + 'permissionRequest', 'thinking' ] if (!body.status || !validStatuses.includes(body.status)) { return errorResponse(`Invalid status. Must be one of: ${validStatuses.join(', ')}`, 400) diff --git a/server/services/terminal.ts b/server/services/terminal.ts index 3bda363..50e2409 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -214,7 +214,7 @@ export function startTerminalServer() { } // Claude status types -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' // Broadcast Claude status to ALL clients across ALL sessions export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string) {