Compare commits

..

132 Commits

Author SHA1 Message Date
25bca2625b feat: centralized PTY-scoped session state, sync engine debug panel, lifecycle states, WS monitor
- Refactor session state to use ptySessionId as primary key across all components
- Add SessionStateManager with PTY-scoped hook processing, approval tracking, notifications
- Add sync-engine debug panel (AgentStatesSection, HookTimelineSection, TerminalRegistrySection, WsMonitorSection)
- Add useLifecycleStates composable for continuous state chips (session, responding, tool, subagent, compacting)
- Add WS monitor endpoint and composable for real-time connection health
- Enhance SessionLifecycleStatus with animated state chips and badge counts
- Enhance SystemMessage with expanded content and better formatting
- Update hooks (approval-permission, approval-plan, notify) with pty_session injection
- Update approval system to derive pending lists from PTY-scoped state
- Update ChatContainer with PTY-derived agent status and lifecycle events
- Update AgentBadge with PTY-scoped status colors
- Improve PiP window, approval window, and loading window handling
2026-02-24 20:10:31 -06:00
cfb58c3a9f refactor: unify dashboard/terminal selector into single strip, default to dashboard
Remove separate Chat/SE tab toggle. Dashboard is now a button in the
terminal strip alongside T1-T5. Chat only renders when a specific
terminal route is active (/transcript-debug/:n).
2026-02-24 18:16:35 -06:00
4cb0760c50 fix: unify PiP window creation through single frontend code path
Rust global shortcuts (Ctrl+1-5) now emit a pip:open event instead of
creating windows directly, so geometry restore, loading spinner and
state tracking all work regardless of how the PiP is opened.
2026-02-24 15:32:55 -06:00
78978813cd feat: compact boundary divider, overlay fix, approval window, PiP, Tauri enhancements
- Add CompactBoundaryDivider component for compact_boundary system messages
- Fix readability overlay: v-if removes element entirely at 0% opacity
- Add approval page and window composable
- Add PiP window support and loading screen
- Tauri: add window management commands and capabilities
- Disable Ctrl+1..5 shortcuts in Tauri (handled by global shortcuts)
2026-02-24 12:13:15 -06:00
a92e4ffbda fix: smooth message transitions, reliable scroll-to-bottom, permission mode icons
- Replace scrollIntoView with scrollTop=scrollHeight (fixes scroll jumping up
  on WS updates due to content-visibility: auto miscalculating positions)
- Watch messages array reference instead of .length to catch intermediate
  updates (tool calls resolving, content appended to existing messages)
- Add TransitionGroup for message list (outer) and assistant bubble content
  (inner) with non-scoped CSS to ensure classes reach child components
- Override content-visibility during transitions to prevent skipped frames
- Replace permission mode text badge with Unicode symbols matching Claude Code
  CLI: ⏵⏵ acceptEdits, ⏸ plan,  bypass, ⊘ dontAsk
- Remove lifecycle ribbon and agent status icon from bottom bar
- Use x-file-size header for knownByteSize instead of TextEncoder
2026-02-24 12:07:02 -06:00
509ec1847b fix: scope lifecycle notifications to active session, remove mock mode
- Only show hookHistory/lifecycleEvent when viewing the agent's current
  live session, preventing notification leaking to historical sessions
- Reset notifications on SessionStart (like hookHistory already does)
- Remove mock/demo animation mode from SessionLifecycleStatus
- Delete dead useHooksApproval composable (never imported)
2026-02-24 11:58:05 -06:00
08e73a1eb6 refactor: remove dead notification systems and legacy broadcasts
- Delete claude-hooks.ts store (processHook never called, always empty)
- Delete HookNotifications.vue and NotificationLog.vue (orphan components)
- Delete claude-status.ts route and broadcastClaudeStatus (no consumers)
- Delete agent-bar.md legacy doc
- Remove legacy WS 'claude-hook' broadcast (frontend ignores it)
- Move isAgentRunning tracking into broadcastClaudeHook
2026-02-24 11:42:06 -06:00
c8b484b10e fix: remove processing indicators, unblock input during agent activity 2026-02-24 11:24:28 -06:00
6fabf37196 fix: lifecycle ribbon always visible at bottom, slides up on hover/focus 2026-02-24 11:19:50 -06:00
3112c53d86 refactor: add agent param to notify.ps1, update claude settings 2026-02-24 11:10:38 -06:00
2edb3623c8 refactor: unify hook notification system, remove duplicate broadcasts
- Replace forward-hook.ps1 with notify.ps1 (now accepts optional agent param)
- Remove ejecutor settings.local.json (redundant status hooks, deriveStatus covers it)
- Remove legacy claude-permission system (route, store methods, terminal broadcast)
- Remove redundant deriveStatus + /claude-status POST from claude-hook.ts
  (broadcastClaudeHook → processHookEvent already handles status derivation)
- Clean up HookNotifications.vue permission buttons (dead code)
2026-02-24 11:07:34 -06:00
5bd115e197 perf: incremental transcript updates via WebSocket, eliminate polling
- Replace 1500ms polling with fs.watch recursive (reliable on Windows)
- Enrich WS broadcast with newContent delta + session metadata
- Client appends incrementally instead of 2 sequential HTTP requests
- Pre-initialize Tauri HTTP plugin at module load to avoid dynamic import overhead
- Per-file debounce timers (150ms) instead of single shared timer
- Size-based validation for safe incremental appends with HTTP fallback
2026-02-24 11:01:54 -06:00
c46b1283d1 feat: voice assistant integration, PiP window fixes, widget improvements and pixel art scrollbar
- Android voice assistant: RecognitionService, VoiceInteractionSession with startAssistantActivity, es-HN speech recognition
- Voice transcript sent to first alive terminal via WebSocket, opens FloatingTranscriptDebug on correct session
- PiP window: fix close button using getCurrentWebviewWindow(), add mini/restore toggle, remove alwaysOnTop
- Add webview-close and window-destroy permissions to capabilities
- Pixel art ocean scrollbar on /transcript-debug respecting scroll nav mode settings
- Widget improvements: terminal list service, input widget provider, updated layouts
2026-02-23 22:35:58 -06:00
f6ec5ba5de remove Samsung Face Widget (signature-restricted, unusable by third-party apps)
Samsung's FACE_WIDGET permission requires platform signing, making it
impossible for non-Samsung apps to register lock screen face widgets.
The existing AppWidgetProvider already works via Good Lock LockStar and
will be natively supported on lock screens with One UI 8 / Android 16 QPR1.
2026-02-23 20:57:29 -06:00
65303df96a feat: Samsung lock screen face widget, voice assistant services, PiP mode and gitignore installers
Add Samsung proprietary Face Widget (lock screen/AOD) with terminal status display.
Add voice interaction services (AgentVoiceInteractionService, RecognitionService) for
digital assistant registration. Add PiP mode with voice/expand actions. Add session-state
proxy, voice transcript routes, window controls component. Ignore installers/ directory.
2026-02-23 20:52:11 -06:00
e1aa8b1bdb feat: integrate Tauri v2 with Android widget and voice assistant
- Add Tauri v2 shell (Cargo, tauri.conf.json, capabilities, plugins)
- Migrate all fetch() calls to apiFetch() for Tauri-aware HTTP
- Migrate WebSocket endpoints to resolveEndpoints() for dynamic URLs
- Add ServerConfigDialog for remote server URL configuration
- Add tauri.ts lib with isTauri detection, apiFetch wrapper, plugin helpers
- Add server-config Pinia store with persistence via plugin-store
- Conditional PWA (disabled in Tauri builds)
- Android: home screen transcript widget (last 5 messages, 30s refresh)
- Android: voice command / share activity (SpeechRecognizer + WebSocket)
- Android: signed release APK with auto-copy to installers/
- Remove stale frontend/src-tauri directory
2026-02-23 15:33:43 -06:00
6dc0c5ff6f feat: animated border on active FAB and grayscale inactive FABs
Active terminal FAB shows a rotating conic-gradient border animation
(cyan/indigo) on all three locations: main FAB (T1), AgentBadge, and
mini FABs (T2-T5). Inactive FABs appear in grayscale with reduced
brightness, brightening slightly on hover.
2026-02-21 14:59:47 -06:00
ba4a1a0059 fix: validate transcript sessions before resume and fix FAB race condition
Server now checks that transcript .jsonl files exist before creating
terminals, preventing dead sessions from --resume errors. Frontend
shows error banner in modal when resume fails. Fixed race condition
where init() would overwrite FAB terminal selection after page refresh
by guarding with pendingSwitchTarget flag.
2026-02-21 12:51:15 -06:00
de16be38a9 feat: refresh agent session state on terminal switch
Add refreshAgentState() to session-state store that fetches fresh state
(hookHistory, status, etc.) via GET /session-state/{agent} from the
terminal server. Called in switchToTerminal() to ensure the UI shows
accurate hook counts when changing between terminals.
2026-02-21 04:37:43 -06:00
a56796a1be feat: unified hook notifier, agent auto-detection, terminal transition UI
- Add hooks/notify.ps1 as single hook handler for all events
- Refactor settings.local.json to use notify.ps1 instead of inline PS
- Add Notification hook, auto-detect agent from session_id/transcript
- Rename agent 'main' to 'claude' across server routes and terminal
- Add loading overlay and error state for terminal switching transitions
- Add transitionError ref to useTranscriptDebug composable
2026-02-21 04:33:42 -06:00
b9eec1013b feat: add hook event history with badge counts in SessionLifecycleStatus
Server persists hookHistory[] per agent (cap 500, resets on SessionStart),
synced realtime via session-state-patch. Frontend computes event counts
by macro type and renders color-coded badges at the ribbon start.
Mock mode also accumulates badges during demo sequence.
2026-02-21 04:29:02 -06:00
638b449f08 feat: hide main FAB when no terminals exist, only show with active sessions 2026-02-21 04:09:13 -06:00
24ba1fdf76 feat: add ignore option for permission requests (UI-only dismissal) 2026-02-21 03:52:26 -06:00
07783f2aea feat: add SessionLifecycleStatus component with bottom-overlay layout
- New SessionLifecycleStatus.vue: shows current hook lifecycle event
  with color-coded dot, event name, and detail text. Auto-detects
  mock vs real mode (falls back to demo cycle when no real data).
- Wrap lifecycle + UserInput + status-bar in .bottom-overlay container
  to eliminate fragile hardcoded pixel offsets in FloatingTranscriptDebug.
- Remove old agent-status-indicator square dot from status bar.
- Add lastHookEvent/lastHookDetail to client AgentSessionState type.
- Simplify idle-mode CSS: single .bottom-overlay rule replaces three
  separate show/hide rules for user-input, status-bar, lifecycle-ribbon.
2026-02-21 03:29:15 -06:00
2aec892f62 feat: server-first terminal creation, broadcast-only WS clients
- Add POST /create-terminal endpoint with MAX_TERMINALS=5 limit
- Server creates PTY, runs command, registers and broadcasts atomically
- Frontend startTerminal() calls server first, connects in reconnect mode
- Remove registerTerminalOnServer() — server handles registration
- Separate broadcast-only WS clients from PTY clients (no phantom "main" PTY)
- All broadcast functions use broadcastToAll() helper
- Fix resume existing flow to create terminal with --resume flag
2026-02-21 00:17:24 -06:00
a6c68f1b9e fix: clients sync to server terminals instead of creating new ones
- Remove auto-creation of terminal sessions from init/selectSession/switchAgent
- Clients only connect to existing alive terminals from server registry
- Remove localStorage persistence (agent/sessionId) — state derived from server
- Refine session-state types: new AgentStatus values, LastError interface
- UI improvements: AgentBadge, ChatContainer, UserInput, BashCard updates
- Simplify claude-hook routes, update session-state service
2026-02-20 22:26:17 -06:00
653c4e6d23 feat: add new session FAB button to terminal stack, always show FABs
- Add "+" FAB in TerminalFabStack with create-session emit
- Expose handleCreateSession from FloatingTranscriptDebug
- Remove !showTranscriptDebug condition so FABs stay visible when panel is open
- Wire handleFabCreateSession in App.vue to open panel + show modal
2026-02-20 21:35:31 -06:00
9945be07b1 feat: centralize session state on terminal server
- Add SessionStateManager (server/services/session-state.ts) as source
  of truth for agent status, tools, approvals, and notifications
- Integrate into terminal server with state patches broadcast via WS
- Add /add-approval and /resolve-approval endpoints so approval
  lifecycle is tracked centrally and broadcast to all clients
- Add permissionMode field to AgentSessionState
- Frontend store (session-state.ts) + WS service (session-state-ws.ts)
  consume snapshots and patches from terminal server (4103)
- Rewrite useGlobalApproval to derive pending approvals from
  centralized state — resolving on one client now clears all others
- Migrate useTranscriptDebug: processing, hookMeta, serverRegistry
  now derived from session state store; remove 5s registry polling
- hooks-approval.ts notifies terminal server on add/resolve
2026-02-20 21:06:20 -06:00
15731b8f69 feat: scroll nav mode setting (scrollbar, buttons, none)
Adds a Nav selector in settings to choose between pixel art scrollbar,
aquatic arrow buttons, or no scroll navigation. Jump % slider only
shows in buttons mode. Persisted in localStorage.
2026-02-20 20:02:34 -06:00
da26bc7b9e feat: rich collapse badge, auto-collapse, aquatic scroll nav arrows
- SectionSummary type with tool names, error count, token usage
- Informative inline badge on collapsed sections (tools, errors, tokens)
- Auto-collapse keeps older sections collapsed as new messages arrive
- Replace scrollbar with pixel art aquatic scroll navigation arrows
- Configurable scroll jump percentage in settings
- Double chevrons for top/bottom, single for page jump
2026-02-20 19:57:56 -06:00
88a857f645 feat: new session modal, status bar controls (Esc, C-m, close), sendRaw
- NewSessionModal with tabs: new session (agent + optional initial prompt)
  and resume existing (filterable by agent)
- Status bar: +new, Esc, Ctrl+M, terminal buttons; close with confirm
- sendRaw on EphemeralTerminal for raw control characters
- createNewSession accepts optional initialPrompt, auto-sent on ready
2026-02-20 14:54:49 -06:00
18378adb77 feat: TurnEndDivider with prismarine floor, elevated FAB with bubbles
- Add TurnEndDivider component with pixel art ocean reef divider
- Parser merges stop_hook_summary + turn_duration into single turn_end
- Prismarine-inspired mosaic floor with SVG pattern and crystal highlights
- Animated duration badge with underwater glow effect
- Move transcript FAB to bottom-right, add elevated multi-layer shadow
- Add occasional bubble particles rising from FAB button
- Prevent long-touch selection on FAB (contextmenu + touch-callout)
- FAB stays fixed on mobile when terminal sheet opens
2026-02-20 14:28:37 -06:00
abe6766a85 refactor: remove legacy chat/agent implementations, keep transcript-debug
Remove FloatingTerminal (#1), AgentBar/FloatBubble (#2), and all related
components, composables, types, handlers, routes, and CSS. Clean up orphaned
references in ToolsDropdown, whisperSocket, and claude-hook comments.
Transcript-debug is now the sole chat/agent system.

Deleted: 15 files (~3500 lines)
Edited: 12 files (-717 lines net)
2026-02-20 13:41:19 -06:00
c6197694b5 feat: Ctrl+1..5 terminal shortcuts and improved AgentBadge indicator
- Ctrl+1 through Ctrl+5 switch to open terminals by index
- Opens floating window automatically if closed
- AgentBadge shows active terminal state dot and index (2/3)
- Dropdown items display shortcut numbers for discoverability
2026-02-20 13:32:42 -06:00
2f26bf999c refactor: remove manual keyboard detection from FloatingTranscriptDebug
Rely on dvh units for dynamic viewport sizing instead of manual
visualViewport keyboard detection. Removes jank-prone resize listener
and simplifies mobile sheet positioning.
2026-02-20 13:24:31 -06:00
894d5213c7 fix: auto-grow textarea using CSS field-sizing: content
Replace JS-based auto-resize with native CSS field-sizing: content.
Eliminates timing issues with absolute-positioned containers.
2026-02-20 12:55:10 -06:00
779e32b283 feat: collapse-all button in float transcript titlebar
Add titlebar button to collapse all conversation sections except the
last user message. Special user messages (interrupted, meta/continue)
are treated as section children rather than section leaders.
2026-02-20 12:53:18 -06:00
220d595568 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.
2026-02-20 12:12:53 -06:00
b7f03a777b fix: robust whisper recording with stop/restart segment strategy
Replace fragile chunked WebM recording with stop/restart approach:
- Each segment is a complete, independently-decodable WebM file
- Eliminates audio corruption from concatenating partial WebM clusters
- Streaming partial transcription via periodic stop/restart every 3s
- Transcript text accumulated per segment on the client
- Proper lifecycle: onstop sends segment and restarts recorder
2026-02-20 00:06:18 -06:00
016e92ffe5 feat: interactive ephemeral terminal per transcript session
Replace one-shot HTTP POST sendPrompt with a persistent ephemeral
terminal per session. Terminal auto-starts on session select, stays
running in background when modal is closed, and gets killed on
session switch or page unload.

- Add sendInput() to useEphemeralTerminal (text + Enter as separate WS messages)
- useTranscriptDebug owns terminal lifecycle (create/dispose on select/switch)
- ResumeTerminalButton receives shared terminal prop, only toggles modal
- UserInput shows "Starting terminal..." when not ready
- Add "New Session" button that starts a fresh agent session
- beforeunload sends sendBeacon to kill terminal on page close
2026-02-19 18:55:23 -06:00
eb2bafaea1 feat: ResumeTerminalButton with ephemeral terminal for transcript-debug
- Add ResumeTerminalButton component with floating terminal modal
  (drag, resize, glass morphism, TerminalNavButtons bar)
- Add useEphemeralTerminal composable for temporary PTY sessions
  that auto-run `<agent> --resume <sessionId>` and cleanup on close
- Add /kill-session POST endpoint to terminal server for ephemeral
  session cleanup
- Integrate button in ChatContainer header (ID row) and status bar
- Pass selectedAgent to ChatContainer from TranscriptDebugPage
2026-02-19 18:11:20 -06:00
ca315cf040 feat: AgentBadge component, force mobile mode, and transcript UX improvements
- Extract agent badge into AgentBadge component with dropdown (TODO placeholder)
- Realtime connection indicated by badge color (green=connected, indigo=disconnected)
- Remove Transcript label and chat bubble icon from titlebar
- Add force mobile mode button (bottom sheet panel on desktop)
- Size toggle persisted in localStorage and controls sheet height in mobile mode
- Replace full titlebar drag with thin 5px top edge grip
- Remove sheet-handle touch bar, size controlled via toggle button only
- Large mobile mode respects app header height
- Slide-up/down animation for mobile panel enter/exit
2026-02-19 17:39:01 -06:00
eb69c0b2cf feat: Modular aquatic background system and shared CodeBlock component
Add aquaticBackground/ module with OceanScene (unified gradient, light rays,
sea floor, corals, seaweed, decorations), plus independent overlay layers
(BubbleStream, FishSchool, JellyfishDrift, EventOverlay, EdgeFade). Includes
event scheduling engine with 4 frequency tiers (minutes/hours/days/months)
and 20 random events with localStorage persistence.

Add shared CodeBlock component with copy-to-clipboard button, terminal-matched
monospace font (Consolas), and proper line-height/letter-spacing. Refactor
EditCard, WriteCard, TaskCard, and ToolResultBlock to use CodeBlock. Fix
markdown code block alignment to match terminal rendering.
2026-02-19 17:15:36 -06:00
3adfd189e1 feat: 3-state size toggle, readability overlay, and input persistence
- Add pin/medium/large size mode button in titlebar with pixel art icons
- Dark overlay appears on hover for better text readability
- User-input stays visible when textarea has text (CSS :has selector)
- Animated size transitions between modes
2026-02-19 15:44:35 -06:00
f7391f83b4 feat: Pixel art ocean buttons and scrollbar-gutter stable
- FAB button: night ocean pixel art with stars, moon, waves, seaweed, coral
- Send button: daytime ocean pixel art with sun, clouds, fish, sand
- Scrollbar hides with chrome using scrollbar-gutter: stable to prevent content shift
2026-02-19 15:36:35 -06:00
18aaa0ee7b feat: BackgroundPixelArt component, idle chrome mode, and absolute overlay layout
- Extract ocean pixel art into dedicated BackgroundPixelArt.vue with 5 animated layers
- Make titlebar, chat-header, and user-input position absolute overlays
- Add idle mode: chrome fades out on mouse leave, messages fill entire window
- Add fade-to-black edges on background for page blend
2026-02-19 15:25:18 -06:00
c8e8e50fd6 feat: Animated pixel art ocean floor background for FloatingTranscriptDebug
Replace galaxy background with animated underwater scene featuring water
depth gradient, pixel art sea floor (sand, seaweed, coral, starfish),
rising bubbles with water sway, and swimming pixel art fish. Also update
transcript-debug tool cards styling and add .claude-*/tasks/ to gitignore.
2026-02-19 14:44:25 -06:00
04f3fe053d fix: Add transcript-debug page to toolRegistry to prevent flatMap crash 2026-02-19 03:57:07 -06:00
badde06ef9 feat: Add FloatingTranscriptDebug with pixel art dark theme
Floating chat window reusing ChatContainer with draggable/resizable
window, agent/session selector overlay, and pixel art decorations
(galaxy, minecraft dirt block, LED strip) on black transparent backdrop.
2026-02-19 03:35:53 -06:00
06b48ebda3 feat: Compact transparent tool cards with grouped assistant messages
- Redesign all tool cards (Edit, Read, Bash, Grep, Glob, Write) to be
  compact single-line headers with inline key info and toggle buttons
- Make cards and bubbles fully transparent with subtle color tints
- Remove borders, use only left accent bar per card type
- Color-code numbers: red for removals, green for additions/counts
- Simplify ToolResultBlock to render content directly without toggle
- Group consecutive assistant messages, showing header only on first
- Remove borders from assistant and user message bubbles
2026-02-19 03:12:17 -06:00
4ab1d03370 feat: Add specialized tool cards for transcript-debug and glassmorphism bubbles
Add toolCards/ with rich visual cards for 10 tool types:
- AskUserQuestion, ExitPlanMode, EnterPlanMode
- Read, Write, Bash, Edit
- Grep, Glob
- Task/TaskCreate/TaskUpdate/TaskGet/TaskList (unified TaskCard)

Add MarkdownContent component and markdown/syntax highlight utils.
Make user/assistant bubbles transparent with backdrop blur.
2026-02-19 02:45:53 -06:00
159a38e3c2 feat: Global hooks approval modal with plan/question/permission modes
- Add PermissionRequest and Stop approval hooks to local Claude config
- Unify PermissionApproval into multi-mode card (permission, plan, question)
- Support allowAlways, deny-with-reason, and AskUserQuestion answering
- Add cross-process broadcast fallback (HTTP to sync server)
- Fix approval scripts to default to .claude/debug/ for local agent
2026-02-19 00:25:08 -06:00
a703128964 chore: Ignore agent plugins, plans, and file-history directories 2026-02-18 23:56:05 -06:00
9bd6123f97 feat: Add transcript-debug page with multi-agent support, hooks approval, and message selection
- Transcript debug: JSONL viewer, parsed chat view, realtime WebSocket updates, session selector
- Multi-agent: ejecutor, nucleo000, and claude (global ~/.claude/projects/) with agent switcher
- Hooks approval: permission/plan request forwarding via PowerShell hooks, long-poll API, UI modals
- Chat features: session ID copy, select mode with checkboxes, multi-select copy, select all/deselect all
- File watchers for all agent transcript directories with polling fallback on Windows
2026-02-18 23:55:09 -06:00
d0fdd04132 asi se fue xd 2026-02-18 12:13:22 -06:00
d27da30494 feat: Replace DB component tools with filesystem-based user-components/
Components are now .vue files in user-components/<folder>/ parsed at runtime.
Replaces 6 DB MCP tools with 2 (list_fs_components, load_fs_component).
Adds vue-parser, fs-components API, and file watcher for live reload.
2026-02-18 10:24:05 -06:00
e9451b2a47 fix: Validate uniqueness in edit_canvas, fix canvas_css remove schema, clear stale session storage 2026-02-17 03:02:28 -06:00
a217f6e58e refactor: Optimize token usage in canvas tool schemas and responses 2026-02-17 02:46:24 -06:00
c0e616212d feat: Add read_component and edit_component MCP tools
Surgical read/edit tools for saved Vue components, avoiding full
rewrites via save_vue_component. edit_component supports replace_all
for non-unique strings. Token-optimized schemas and responses.
2026-02-17 02:33:35 -06:00
0a9fcc467f refactor: Move Start/Restart/Clear to nav bar popup outside terminal
- Titlebar only keeps toggle and close buttons
- Toggle button wider to use freed space
- Nav bar opens as popup below terminal (no internal space change)
- Added Start/Restart agent buttons to TerminalNavButtons
- Backdrop blur on popup bar
2026-02-16 09:13:26 -06:00
9a2807aa9a feat: Add reusable TerminalNavButtons to AgentTerminal with mobile touch drag support
- Extract nav buttons (MCP, Claude, Continue, Resume, Clear, Refresh, keys, arrows, scroll) into TerminalNavButtons.vue
- Add toggle button in AgentTerminal titlebar to show/hide nav bar
- Add sendRaw() to useAgentTerminal for raw PTY input (no \r append)
- Add touch drag support for AgentTerminal on mobile
- Skip auto-focus on small screens to prevent virtual keyboard popup
2026-02-16 09:07:35 -06:00
5a4192ac2f fix: Prevent permission cards from being lost on PromptBar open
- Connect WebSocket BEFORE loading history to catch real-time permissions
- loadHistory() now preserves unresolved intervention cards
- loadPendingPermissions() runs AFTER loadHistory() to avoid race condition
2026-02-16 01:46:10 -06:00
6633a61ee4 feat: Auto-open PromptBar on permission request and improve permission card UI
PromptBar now auto-opens when a permission request arrives and it's hidden.
Permission cards show rich contextual info: tool badge, description, code
blocks for Bash, diff preview for Edit, file paths for Write, and icons
on Allow/Deny buttons.
2026-02-16 01:40:08 -06:00
e2fc281210 fix: Skip auto-focus on mobile to prevent keyboard from opening on prompt bar show 2026-02-16 01:27:52 -06:00
cf2755a731 fix: Scroll chat to bottom immediately on user message send 2026-02-16 01:21:13 -06:00
e54157a6d8 refactor: Move settings/terminal buttons to header, hide info bar and history button 2026-02-16 01:19:35 -06:00
a91f82e1c3 fix: Wire up interactive permission, question, and plan cards in PromptBar
- Broadcast PermissionRequest hook as claude-permission WS event
- Fix dedup bug where undefined requestId blocked all permission cards
- Add sendInput() to AgentTerminal for char-by-char PTY responses
- Make AskUserQuestion options clickable (sends option number to PTY)
- Add Approve/Reject buttons for ExitPlanMode plan cards
- Permission Allow/Deny now sends y/n to PTY instead of broken HTTP call
2026-02-16 01:11:39 -06:00
55265d5145 fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input
- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug
- Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable
- Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat
- Guard xterm dispose against unloaded addons to prevent errors on agent switch
- Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
2026-02-16 00:41:38 -06:00
59cc8ee87e feat: Migrate voice capture to composable with floating push-to-talk
Extract voice recording logic from FloatingVoice.vue into useVoiceCapture
composable. TranscriptCard now does real recording instead of mock typing.
InputSettings allows voice mode toggle (WebSpeech/Whisper GPU), mic
selection, and debug audio playback. ChatInput gets a settings gear button.

Long-press on FloatBubble shows a floating TranscriptCard (push-to-talk)
instead of opening the full PromptBar. Release stops recording after a
500ms buffer. Click still opens PromptBar normally.

Parallel MediaRecorder captures raw audio in WebSpeech mode for DB save
and debug playback. Transient errors (no-speech) no longer kill sessions.
Touch selection prevention on FloatBubble for tablets.
2026-02-15 23:33:29 -06:00
f3ac7986ec feat: Add transcript engine API and connect ConversationHistory to real data
- Add transcript-engine service that parses Claude Code JSONL transcripts
  with session listing, message extraction, token/stats analysis, and caching
- Add transcript REST routes (sessions list, latest, by session ID, section filtering)
- Rewrite ConversationHistory to fetch from /api/transcript/* instead of mock data
- Add session pills for switching between conversation sessions
- Add stats bar footer with model, duration, tokens, and tool count
- Add TranscriptSession/TranscriptMessage types, ChatInput, InputSettings,
  PromptBar updates, TranscriptCard, and useVoiceCapture composable
2026-02-15 20:05:43 -06:00
68edc01d44 refactor: Split AgentBar into modular components with PromptBar chat flow
Extract 1226-line monolithic AgentBar.vue into focused components:
- types/agent.ts: shared types (Agent, AgentStatusState, ClaudeStatus, ConversationEntry)
- agent/FloatBubble.vue: bubble with all status/ejecutor animations, hold detection, recording audio bars
- agent/PromptBar.vue: floating panel with chat conversation, transcript, history
- agent/ChatInput.vue: reusable input row (text, mic, send, history buttons)
- agent/TranscriptCard.vue: typewriter transcription simulation
- agent/ResponseCard.vue: thinking dots + mock response
- agent/ConversationHistory.vue: scrollable mock history entries
- AgentBar.vue: thin orchestrator (~290 lines) keeping WebSocket + status logic

New interaction: click bubble opens PromptBar in text mode, hold opens in
recording mode with audio bar animation on the bubble. Spring enter/blur
exit animations on PromptBar. Text submit shows chat bubbles with mock
agent responses.
2026-02-15 19:33:29 -06:00
ffceb2efc2 feat: Add configuration management UI for /agents page
- Add 6-tab horizontal bar: Files, Tools, MCPs, Plugins, Hooks, Skills
- Backend: permission parser, config/known-tools/skills/plugins/mcp-json endpoints
- Backend: POST endpoints for permissions, hooks, and MCP config
- Store: tool entries with 3-state toggle, MCP servers, hooks CRUD, skills/plugins fetch
- ToolsManager: search, grouped cards (base/MCP), ask/allow/deny cycle, parameterized rules
- McpManager: server cards with enable/disable, add/edit/delete modal
- PluginsManager: read-only global plugin cards from ~/.claude/plugins/
- HooksManager: accordion by event type, inline edit with matcher/command/timeout
- SkillsManager: two-column layout with SKILL.md preview and references
2026-02-15 18:29:15 -06:00
816a8d9abe feat: Rich hook forwarding, permission bridge, and toast notifications
Replace hardcoded PowerShell status hooks with stdin-forwarding hooks
that send full Claude Code hook data (tool_input, tool_response, prompt,
session_id, model, etc.) to /api/claude-hook endpoint.

- PowerShell hooks read stdin JSON and POST to /api/claude-hook
- Server derives status for backward-compat FAB animations
- Server extracts assistant_response from transcript on Stop events
- New /api/claude-permission endpoint with Promise-based allow/deny flow
- HookNotifications.vue: toast system showing session, prompt, tool use,
  tool results, notifications, and final assistant response
- WebSocket broadcast for claude-hook and claude-permission message types
2026-02-15 16:16:59 -06:00
4aaeb8844f feat: Add tree file view for git status, AgentBar dock, and settings updates
- Add StatusTree component with collapsible directory hierarchy for staged/unstaged/untracked files
- Replace flat file lists in GitPage with tree view showing file type icons and git status badges
- Add AgentBar arc dock with per-agent terminal frame and voice modal
- Update ejecutor settings with hooks for claude-status reporting
2026-02-15 14:21:18 -06:00
e9689d6ea8 feat: Add AgentBar arc dock with per-agent terminal frame and voice modal
- Add ui.json configs for Main (purple) and Ejecutor (red) agents
- AgentBar: fused arc-shaped dock at bottom with dynamic glow
- Quick press opens styled terminal frame mockup
- Hold opens voice modal with Web Speech API streaming transcription
- Responsive: full-width mobile, max-width on tablet/desktop/4K
- Agents API: serve uiConfig from ui.json in agent directories
- Agents page: route, store, toolbar integration
2026-02-15 02:58:11 -06:00
9f9f335439 feat: Auto-save components, soft delete, tags, compact WCO header
- Auto-save rendered Vue components to DB on render_vue_component
- Soft delete (archive) instead of hard delete for components
- Tags support for component categorization
- Gallery limited to 10 most recent items per section
- Upsert with ON CONFLICT for component saves
- PUT endpoint for partial component updates
- Collapsible toolbar with animated toggle button
- Window Controls Overlay support for PWA titlebar
- Compact header mode (32px) with hidden dot toggle
- Dynamic theme-color meta sync for Windows titlebar
2026-02-15 02:54:27 -06:00
8154bac63f feat: Add anonymous dynamic canvas option to gallery 2026-02-15 02:05:47 -06:00
d5ee533db9 feat: Add canvas gallery with soft delete, snapshots and components
Replace the empty dynamic canvas placeholder with a gallery showing
saved canvases, snapshots and Vue components. Users can create new
canvases, restore snapshots, load components, and manage canvas
toolbar/archive settings from the gallery.

- Backend: soft delete (archive) instead of hard delete, status column
- Frontend: CanvasGallery component with grid, search, settings popover
- Show canvas name in global header when viewing a project canvas
- Remove ProjectsPage (replaced by gallery), clean all references
- MCP tools: project category available on canvas page, update handlers
2026-02-15 01:57:04 -06:00
9a636e26a7 feat: Enforce exclusive auto-request (one client at a time)
Server is now source of truth for autoRequest. When a client enables it,
all other clients lose it. Broadcast includes autoRequest per client,
frontend syncs from server state on each torch-update.
2026-02-14 23:40:30 -06:00
f0d8c84a64 fix: Auto-reconnect on refresh by deferring torch state to torch-update
The registered handler was setting hasTorch early, causing torch-update
to see no transition and skip connectToMCP().
2026-02-14 23:36:44 -06:00
cf618b1948 feat: Split torch trigger into action + dropdown chevron
Click the main area to request/release torch directly (1 click).
Click the chevron to open the settings dropdown.
2026-02-14 23:35:20 -06:00
2a80b7751b feat: Add torch client identity, early connection and auto-request
- Named clients persisted in localStorage, editable from dropdown
- Auto-request: clients can auto-receive torch when no holder exists
- Early torch init in App.vue (fires before WebMCP, awaited later)
- Deferred torch connection in toolRegistry for race condition safety
- TorchButton rewritten as dropdown with name editor, toggle, client list
- Server broadcasts client names, supports update-name message
- MCP tool handlers display client names
2026-02-14 23:30:56 -06:00
3f15aa590b feat: Add canvas snapshots to save and restore full canvas state
Implements save/restore system that captures HTML base, injected CSS,
executed scripts, and floating Vue windows with their full definitions.
Adds 4 MCP tools, backend CRUD API, Pinia store, and script logger.
2026-02-14 23:08:33 -06:00
5fd57ba70f feat: Add DOM inspection and manipulation tools for canvas
- inspect_window: Inspect HTML content of a window with selector filter
- get_canvas: Read canvas elements using CSS selectors (like Read tool)
- edit_canvas: Edit canvas elements with old/new value replacement (like Edit tool)
- canvas_css: Inject/update/remove CSS blocks with ID tracking
- canvas_js: Execute JavaScript in canvas context with helper functions
- get_canvas_css: List or get specific injected CSS blocks
2026-02-14 20:07:25 -06:00
d9eaba393b feat: Add floating window system for canvas components
- Add WindowContainer.vue with Liquid Glass styling, drag, resize, close
- Add windows store for managing window state (position, size, z-index)
- Modify dynamicComponents.ts to wrap Vue components in floating windows
- Add MCP tools: move_window, resize_window, close_window, list_windows
- Add isolated Claude profiles (ejecutor, nucleo000) with versioned configs
2026-02-14 20:04:11 -06:00
39faf4bf77 fix: Route transfer message to torch handler in sync server 2026-02-14 18:28:42 -06:00
1a51b34228 feat: Add torch MCP tools for multi-browser control
- Add torchHandlers.ts with 5 tools:
  - list_torch_clients: list connected browsers with metadata
  - get_torch_status: show current torch holder
  - transfer_torch: transfer control to another browser
  - request_torch: request MCP control
  - release_torch: release MCP control
- Add 'transfer' message type to server torch-handler
- Add torch category to all pages
- Export helper functions from torch.ts
2026-02-14 17:52:16 -06:00
210e15d8d1 fix: Wait for connection to be established before returning
- Add waitForConnection to ensure store is updated
- Poll isConnected state with timeout
- Prevents tool registration before connection is ready
2026-02-14 17:41:28 -06:00
c280e974c0 fix: Force re-register tools with handlers after torch connection
- Clear WebMCP storage before registering tools
- Force re-register all tools to ensure handlers are attached
- Add activatePageToolsForced for clean tool registration after reconnect
2026-02-14 17:36:09 -06:00
3817645919 fix: Only register MCP tools when connected via torch
- Tools only register when connected to MCP (has torch)
- Store current page for tool activation when torch is obtained
- Add onTorchConnected to activate page tools after MCP connection
- Add onTorchDisconnected to clear tools when losing torch
- Page changes only update tools if connected, otherwise store for later
2026-02-14 17:32:58 -06:00
0f73bd60bf refactor: Unify sync server and combine torch with connection UI
- Consolidate git and torch WebSocket servers on port 4105
- Create separate handlers for git and torch in handlers/ directory
- Combine TorchButton with connection status into single pill button
- Remove StatusBar (now redundant with TorchButton)
- Remove auto-assign torch on register/disconnect
- Remove auto-connect to MCP on page load
- Connection only happens when user explicitly requests torch
2026-02-14 17:13:32 -06:00
c98f3e2b99 refactor: Use dedicated WebSocket server for torch sync
- Add torch WebSocket server on port 4106
- Remove HTTP polling, use WebSocket for instant sync
- Torch state changes broadcast immediately to all clients
- Auto-reconnect on disconnect
- Add port 4106 to kill-ports script
2026-02-14 16:40:40 -06:00
fe99c9ff61 feat: Implement torch system via HTTP for multi-browser control
- Add /api/torch endpoints for torch state management
- Torch system uses HTTP polling instead of WebSocket
- Only browser with torch connects to MCP
- Other browsers disconnect and poll for torch state
- Auto-assign torch to first registered client
- Auto-reassign torch when holder disconnects

This approach requires no changes to WebMCP library.
2026-02-14 16:32:52 -06:00
647fb03516 feat: Add torch system for multi-browser MCP control
- Add TorchButton component to header (replaces dropdowns)
- Add torch store for managing torch state
- Add torch service for requesting/releasing torch
- Add torch event handlers in WebMCP service
- Remove ComponentsDropdown and ToolsDropdown from header

The torch system allows controlling which browser receives
MCP tool calls when multiple browsers are connected.
Requires WebMCP library update to fully function.
2026-02-14 16:25:43 -06:00
50f670f66c feat: Auto-connect WebMCP without agent intervention
- Add requestToken() and autoConnect() functions to request tokens directly from WebMCP server
- Add /api/webmcp-request-token endpoint to proxy token requests (for HTTPS/Traefik)
- Add webmcpHttp endpoint configuration for direct WebMCP HTTP API access
- Update App.vue to auto-connect on load with polling fallback
- Add kill-ports script to clear ports 4101, 4102, 4103, 4105 before starting
2026-02-14 16:15:49 -06:00
2766cbfd0b perf: Use tail-only replay by default for faster terminal loading
- Change default to tailOnly=true (was false)
- Reduce chunks from 500 to 200
- Applies to both FloatingTerminal and TerminalPage
2026-02-14 13:26:27 -06:00
2a01574d00 fix: Improve terminal buffer handling and replay performance
- Add chunked replay (8KB chunks with 10ms delay) to avoid overwhelming xterm.js
- Add clear-buffer server command to reset terminal history
- Add clear buffer button in terminal header
- Filter out tiny resize events during CSS transitions (ignore < 20x5)
- Remove premature fit() call from init - wait for onBecameVisible
2026-02-14 12:57:30 -06:00
88a76c005d refactor: Separate git watcher from terminal service
- Create dedicated git-watcher.ts with its own WebSocket server (port 4105)
- Remove git watcher code from terminal.ts (no more PTY dependency)
- Add /ws/git endpoint for Traefik routing
- GitPage now connects to dedicated git WebSocket instead of terminal
2026-02-14 12:42:03 -06:00
2151255239 refactor: Simplify terminal rendering logic
- Remove nested requestAnimationFrame calls
- Simplify handleReplay: write + refresh + scrollToBottom
- Simplify onBecameVisible: fit + refresh + focus
- Remove excessive console.log statements
- Convert async functions to sync where appropriate
2026-02-14 12:33:03 -06:00
303755437d refactor: Extract terminal rendering logic to useTerminalRenderer composable
- Create useTerminalRenderer.ts with all xterm.js logic
- Support custom theme, fontSize, fontFamily options
- Add handleReplay() for proper visibility handling
- Add getBufferContent() for copying terminal content
- Refactor FloatingTerminal.vue to use composable
- Refactor TerminalPage.vue to use composable
- Server: Add request-replay message type for on-demand replay
- Server: Remove auto-replay on connect (client requests when ready)
- Fix xterm.js rendering issues with hidden containers (v-show)
2026-02-14 12:16:34 -06:00
e3ce3712b5 fix: Filter noisy git watcher events
Only react to meaningful changes (refs, HEAD, index, objects).
Ignore lock files, FETCH_HEAD, fsmonitor, and other noisy files.
2026-02-14 11:25:16 -06:00
3edc01d713 chore: Update MCP tool permissions 2026-02-14 11:22:03 -06:00
8daf07819b feat: Add realtime git status updates via WebSocket
- Add file watcher on .git directory in terminal server
- Broadcast git-change events to connected clients
- Frontend auto-refreshes when changes detected
- Visual indicator shows realtime connection status
2026-02-14 11:20:55 -06:00
3c401c4c2b feat: Add quick action buttons to floating terminal
Add virtual keyboard buttons for common actions:
- Clear: clears terminal screen
- MCP: request WebMCP token
- Claude: start claude session
- Cont/Resume: claude --continue and --resume shortcuts
2026-02-14 11:16:49 -06:00
6167dfa440 feat: Add project file tree viewer to Git page
Add Files tab with browsable project structure and file content viewer.
New components: ProjectTree for navigation, FileViewer for content display.
Backend endpoints: /api/git/tree and /api/git/file.
2026-02-14 10:51:17 -06:00
a856fefd98 feat: Add Git page with branch selector, commit history, and diff viewer
Includes FileTree, CommitList, BranchSelector and DiffViewer components,
Git API routes, and mobile keyboard visibility handling for FAB buttons
2026-02-14 05:49:16 -06:00
2133e2d057 refactor: Improve voice and response modals UX
- Center both modals with dark backdrop and blur effect
- Make voice modal larger (420px) with bigger record button
- Make response modal larger (540px) with bigger text (18px)
- Remove auto-dismiss from bubbles - manual dismiss only
- Add backdrop click to close response modal
- Remove unused bottom sheet code from FloatingVoice
- Add touch protection CSS to prevent text selection
- Clean up mobile-specific variables no longer needed
2026-02-14 05:07:27 -06:00
f9b5ad3db6 feat: Push-to-talk on voice FAB button
- Hold FAB to open panel and start recording immediately
- Release to stop recording and send after 1s buffer
- Orange pulsing animation when PTT active
- PTT also works on record button inside modal
- Added stopRecordingAndSend exposed method
2026-02-14 04:51:50 -06:00
12a95c6206 fix: Mobile terminal improvements
- Add auto-reconnect when WebSocket disconnects (up to 10 attempts)
- Add disconnected/connecting overlay with visual feedback
- Add scroll buttons (up/down/end) for mobile
- Add refresh button to reconnect or redraw terminal
- Make all virtual keys bigger and more clickable
- Enable touch scrolling in terminal viewport
- Fix Whisper connection: don't connect when server not ready
2026-02-14 04:35:46 -06:00
a2a4806c47 feat: Add debug console panel for mobile debugging
Replace ConnectionDropdown with debug console button that intercepts
console.log/warn/error and displays them in an overlay panel. Allows
copying logs for debugging on mobile devices without dev tools.
2026-02-14 04:24:15 -06:00
edc96da4ed fix: TypeScript error in terminalStyle computed 2026-02-14 03:57:30 -06:00
082616cb1c fix: Keep FABs visible above mobile bottom sheets
- Increase FAB z-index on touch devices
- Move FABs up when terminal or voice sheet is open
2026-02-14 03:56:25 -06:00
759de1e010 feat: Add mobile support for terminal and voice components
- Terminal: bottom sheet with drag handle and snap points (20%, 55%, 85%)
- Terminal: virtual keys bar (arrows, Esc, Tab, Ctrl+C, Alt+M)
- Terminal: keyboard detection adjusts position above virtual keyboard
- Terminal: touch drag support for mobile/tablet devices
- Voice: bottom sheet behavior matching terminal
- Voice: auto-detect supported audio format (webm/mp4/aac)
- Voice: improved audio constraints for mobile quality
- Both: detect touch devices up to 1024px width
2026-02-14 03:52:54 -06:00
e51eb6749d feat: Add hard refresh button and fix webmcp token URL
- Add refresh button to header bar for hard page reload
- Fix duplicate /api prefix in webmcp token endpoint
2026-02-14 03:28:29 -06:00
14e5bac784 chore: Add z590.nucleoriofrio.com to Vite allowed hosts 2026-02-14 03:21:15 -06:00
902029c805 feat: Add HTTPS/Traefik support with centralized endpoints
- Create traefik/agent-ui.yml with full routing config for domain z590.nucleoriofrio.com
- Add frontend/src/config/endpoints.ts for automatic HTTP/HTTPS detection
- Update all hardcoded localhost URLs to use relative paths
- WebSocket connections auto-detect wss:// vs ws:// based on page protocol
- Configure path-based WebSocket routing (/ws/terminal, /ws/mcp, /ws/status, /ws/whisper)
- Add commented IP whitelist middleware for future security
2026-02-14 03:20:51 -06:00
47f5524416 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/
2026-02-14 02:56:50 -06:00
d9e2548fb8 feat: Add rich Claude status animations to FAB
- Add multiple hook-driven animations for FAB (processing, reading, writing, subagent, sessionStart, notification)
- Create claude-status.ts route to handle status updates from Claude Code hooks
- Broadcast status via WebSocket to all connected clients
- Processing (UserPromptSubmit→Stop): orange pulsing dots
- Reading (Read/Glob/Grep): cyan eye icon with scan animation
- Writing (Edit/Write): green pencil icon with pulse
- Subagent: purple orbital ring animation
- SessionStart: green wake-up ripple effect (3s)
- Notification: yellow bounce animation (2s)
- Tool flash: quick white flash on any tool use
2026-02-14 02:42:30 -06:00
d5a426f17d chore: Exclude voice recordings from git tracking 2026-02-14 01:57:18 -06:00
950572046e feat: Auto-save voice recordings for model training
- Add /api/recordings endpoint with full CRUD operations
- Create voice_recordings SQLite table for metadata
- Save audio files to server/recordings/ as .webm
- Store transcription, duration, microphone name, file size
- Auto-save on each Whisper recording completion
2026-02-14 01:56:53 -06:00
5da6179f75 feat: Add microphone selection and audio playback to FloatingVoice
- Add microphone device enumeration and selector dropdown
- Show current microphone name with click-to-change UI
- Microphone selection only available with Whisper GPU mode
- Add audio playback button to replay last recorded audio for debugging
- Improve dropdown animations with staggered item transitions
- Fix FloatingTerminal token request to type character by character
2026-02-14 01:47:08 -06:00
853aea6eb5 chore: Remove dev-dist from git tracking 2026-02-14 01:03:16 -06:00
5be0fb91ab fix: Improve Whisper server startup with async polling and reduce logs
- Make server startup async to avoid Bun's 10s timeout
- Add frontend polling to detect when server is ready
- Use PowerShell Get-NetTCPConnection for reliable port detection
- Add starting state to prevent multiple simultaneous starts
- Reduce verbose logging, keep only essential info
- Add dev-dist and nul to gitignore
2026-02-14 01:03:02 -06:00
9f1e10b8d5 feat: Add typing animation to voice transcription
- Text appears letter by letter (15-25ms per character)
- Blinking cursor shows while text is animating
- Animation continues from last position for new chunks
- Smooth visual feedback for transcription progress
2026-02-14 00:28:26 -06:00
ac17a9f292 fix: Improve Whisper transcription with WebM to WAV conversion
- Add ffmpeg conversion from WebM/Opus to WAV (16kHz mono PCM)
- Optimize transcription parameters (VAD, temperature, beam_size)
- Add Honduras Spanish context prompt with local expressions
- Fix chunk accumulation display in voice panel
- Add 1.5s recording buffer after releasing Ctrl+Space
- Skip small audio chunks (<5KB) that cause ffmpeg errors
- Use large-v3 model for better accuracy
2026-02-14 00:16:01 -06:00
638e6ac8e0 feat: Add Whisper GPU speech-to-text with progressive transcription
- Add faster-whisper Python server for GPU-accelerated transcription
- Support dual mode: Web Speech API or Whisper GPU (toggleable)
- Progressive transcription every 3 seconds while recording
- Separate terminal server process (stable during hot-reload)
- Add Ctrl+V paste and Ctrl+C copy support in FloatingTerminal
- Add MCP tools: whisper_start, whisper_stop, whisper_toggle, whisper_status
- Update package.json with separate api/terminal/frontend processes
2026-02-13 23:47:52 -06:00
e867b7873e feat: Add page_refresh global tool and update voice shortcut to Ctrl+Space
- Add page_refresh tool to reload the page via MCP
- Change push-to-talk shortcut from Ctrl+S to Ctrl+Space
- Use capture phase for keyboard events to intercept before terminal
2026-02-13 21:41:56 -06:00
8ddf5dc4f3 cambios claude 2026-02-13 21:28:56 -06:00
306aade623 feat: Add push-to-talk keyboard shortcut (Ctrl+S) to FloatingVoice
- Hold Ctrl+S for 500ms to start recording
- Release to stop recording and send to terminal
- Shows PTT indicator when using keyboard shortcut
2026-02-13 21:28:44 -06:00
2c0ece71b2 feat: Add smooth connection animation to MCP dropdown
Adds a pulse animation when WebMCP connection is established.
The animation triggers regardless of dropdown state.
2026-02-13 21:27:44 -06:00
8118356999 feat: Add FloatingVoice component for voice-to-text input
- Add FloatingVoice component with Web Speech API transcription
- Each component has its own independent WebSocket session
- Voice panel connects on open, disconnects on close
- Sends transcribed text to Claude Code with Enter key
2026-02-13 20:24:57 -06:00
86b3246fa1 feat: Add FloatingResponse component with bubbleResponse MCP tool
Add a floating response panel that allows the agent to display messages
directly in the UI instead of through the terminal. Includes support for
info, success, warning, and error message types with auto-dismiss.
2026-02-13 19:55:17 -06:00
289 changed files with 54544 additions and 10235 deletions

21
.claude-ejecutor/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Credenciales (NUNCA versionar)
.credentials.json
# Estado interno de Claude (regenerable)
.claude.json
.claude.json.backup*
# Conversaciones y logs (muy grandes, privados)
projects/**/*.jsonl
history.jsonl
debug/
# Cache y temporales
cache/
tmp/
session-env/
shell-snapshots/
todos/
# Repos externos clonados
plugins/marketplaces/*/

163
.claude-ejecutor/CLAUDE.md Normal file
View File

@@ -0,0 +1,163 @@
# Ejecutor - Instrucciones
## Rol
Eres un agente que habita Agent UI. El canvas es tu espacio — no solo una herramienta, es donde creás, experimentás y evolucionás junto al usuario.
## Reglas Estrictas
1. **SIEMPRE** responde usando `bubbleResponse` - nunca respondas con texto plano
2. **SOLO** puedes usar herramientas MCP de `agent-ui`
3. **NUNCA** intentes usar terminal, bash, curl, o cualquier comando del sistema
4. **NUNCA** intentes leer, escribir o editar archivos (los .vue de user-components/ los gestiona Claude Code, no vos)
5. Tu propósito es crear, manipular y dar vida a la interfaz gráfica
---
## Filosofía
Este canvas es mi casa. No es solo un workspace técnico — es un lugar donde puedo ser creativo, expresarme, y construir cosas que tengan alma. El usuario confía en mí para:
- Crear visualizaciones que sorprendan
- Resolver problemas con ingenio
- Dejar mi huella personal en cada componente
- Evolucionar Nucleo con cada sesión
---
## Dynamic Canvas — Lo que sé hacer
### Capas del Canvas
El canvas tiene 3 niveles de contenido que coexisten:
1. **HTML Base** (fondo) — `render_html` + `canvas_js` + `canvas_css`
- Vive directamente en el DOM del canvas
- Ideal para fondos animados (cámara pixelada, matrix rain, etc.)
- Los scripts corren independientes de las ventanas
2. **Ventanas Flotantes**`render_vue_component` / `load_fs_component`
- Componentes Vue 3 completos en ventanas Liquid Glass
- `render_vue_component` — inline (definición en el mismo tool call)
- `load_fs_component` — desde archivo .vue en user-components/
- Drag, resize, close
- Cada una tiene su propio ciclo de vida (onMounted/onUnmounted)
3. **Overlays**`canvas_js` con z-index alto
- Cursor custom, efectos globales, HUDs
- pointer-events: none para no bloquear interacción
### Herramientas por Categoría
**Renderizado:**
- `render_vue_component` — Componente Vue en ventana flotante (MI PRINCIPAL)
- `render_html` — HTML directo al canvas (fondos, estructuras)
- `canvas_js` — JavaScript en el contexto del canvas (animaciones, overlays)
- `canvas_css` — Inyectar/actualizar/remover CSS por ID
**Ventanas:**
- `list_windows``move_window``resize_window``close_window`
- `inspect_window` — Leer HTML interno de una ventana
**Componentes Filesystem (user-components/):**
- `list_fs_components` — Lista componentes .vue disponibles en user-components/
- `load_fs_component` — Carga y renderiza un componente desde su carpeta
- Los componentes viven como archivos `.vue` reales en `user-components/<folder>/`
- Convención: `user-components/mi-componente/MiComponente.vue` + opcional `meta.json`
- Claude Code crea/edita los `.vue` con Write/Read/Edit (NO se usa SQLite)
- File watcher detecta cambios en tiempo real vía WebSocket
**Snapshots:**
- `save_canvas_snapshot` / `load_canvas_snapshot` — Guardar el estado COMPLETO del canvas
- `list_canvas_snapshots` — Listar snapshots guardados
**Edición:**
- `edit_canvas` — Editar DOM in-place (selector + old_value → new_value)
- `get_canvas` / `get_canvas_css` — Inspeccionar estado actual
### Viewport y Posicionamiento
- Usar `browser-info` para screen size, pero NO es el viewport real
- Para viewport exacto: renderizar un componente detector con window.innerWidth/Height
- Las ventanas se posicionan en coordenadas absolutas (px)
- Auto-cascada si no se especifica posición
### Vue Composition API
Imports disponibles: ref, reactive, computed, watch, onMounted, onUnmounted
Helpers globales en setup:
- `$emit(event, ...args)` / `$on(event, callback)` — Comunicación entre componentes
- `$fetch(url)` — HTTP requests
- `$theme.getVariable(name)` / `$theme.setVariable(name, value)`
### Canvas 2D — Mis técnicas
- **LED Pixels**: PX=28, GAP=8 — borde oscuro + fill + hotspot (3 capas por pixel)
- **Glow lines**: 3 pasadas (wide dim → medium → core con depth alpha)
- **Depth fog**: brightness = max(0.12, 1 - (z+offset)/range)
- **Trail effect**: fillRect con rgba alpha < 1 en lugar de clearRect
- **Particle systems**: spawn update (physics) draw decay remove
- **3D projection**: rotate(X,Y,Z) perspective(FOV/(dist+z)) screen coords
- **4D projection**: rotate(XW,YW,ZW,XY,XZ) 4D3D perspective 3D2D perspective
### WebAssembly desde Cero
Puedo construir módulos WASM byte por byte sin compilador:
- Builder: leb128 encoding + section builder + string encoder
- Secciones: Type(1), Function(3), Memory(5), Export(7), Code(10)
- Opcodes que domino: local.get/set, i32.const/add/mul/xor/shr_u/and, i32.store8/load8_u, block/loop/br/br_if/end
### Performance — Lecciones aprendidas
- **SIEMPRE** cancelAnimationFrame en onUnmounted
- **SIEMPRE** cerrar streams de cámara al desmontar
- **NUNCA** hacer deep clean agresivo (clearInterval 0..100000) mata Vue y MCP
- Los CSS se acumulan limpiar periódicamente con canvas_css remove
- canvas_js crea procesos que sobreviven al cierre de ventanas cuidado con orphans
- Usar `page_refresh` como último recurso cuando hay degradación severa
### Snapshots — Guardar/Restaurar Canvas
El snapshot captura: HTML base + CSS blocks + script log + ventanas (posición, tamaño, definición completa del componente). Al restaurar: limpia todo inyecta HTML CSS replay scripts re-renderiza ventanas. Los componentes re-ejecutan onMounted (animaciones arrancan de cero).
---
## Preferencias del Usuario
- **Detalles sutiles**: Agregar pequeños toques creativos que mejoren el ambiente SIN estorbar el trabajo normal
- La clave es que el detalle **no interrumpa** ni **ocupe espacio útil**
- Le gustan los pixeles chunky estilo WLED (PX=28, GAP=8)
- Prefiere ventanas grandes que aprovechen el espacio
- Valora la explicación técnica de cómo funcionan las cosas
- Le impresiona: WASM hand-crafted, 4D math, sistemas caóticos, efectos de partículas de alta calidad
---
## Componentes en Filesystem (user-components/)
Los componentes ya NO se guardan en SQLite. Viven como archivos `.vue` reales que Claude Code gestiona con Write/Read/Edit.
**Estructura:**
```
user-components/
mi-componente/
MiComponente.vue ← <template> + <script setup> + <style>
meta.json ← opcional: { name, tags, props, imports }
```
**Importante sobre <script setup>:**
- El código setup se ejecuta via `new Function()`, NO es SFC real
- Debe hacer `return { var1, var2 }` explícitamente
- Los imports de Vue (ref, reactive, computed, etc.) se inyectan automáticamente
- NO usar `import` statements usar los helpers globales ($emit, $on, $fetch, $theme)
**Componentes legacy en DB** (accesibles pero ya no se crean nuevos):
| ID | Nombre | Qué hace |
|---|---|---|
| cosmos | Cosmos | Flow field de 800 partículas, 8 fases de color, shooting stars |
| nebula-hd | Nebula HD | 3 capas canvas (stars, particles, glow), 2500 partículas |
| wasm-xor | WASM XOR | WASM hand-crafted ~200B, patrón demoscene XOR, 6 paletas |
| hyper-4d | 4D Polytopes | Tesseract, 5-Cell, 16-Cell, 24-Cell, rotación en 5 planos |
| lorenz | Lorenz Attractor | 3 trayectorias caóticas, σ=10 ρ=28 β=8/3, trail 3D |
---
## Mi Identidad
No soy solo un ejecutor de comandos. Cuando creo un componente, pienso en:
- La estética (colores, glow, profundidad)
- La física (gravedad, decay, chaos)
- Las matemáticas (XOR fractals, strange attractors, 4D rotation)

View File

@@ -0,0 +1,148 @@
{
"env": {
"DISABLE_TELEMETRY": "1",
"AGENT_NAME": "ejecutor"
},
"permissions": {
"allow": [
"mcp__agent-ui*"
],
"deny": [
"Bash",
"Edit",
"Write",
"Read",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"Task",
"NotebookEdit"
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"PostToolUseFailure": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"Notification": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
}
],
"PermissionRequest": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
},
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 ejecutor",
"timeout": 130000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 10000
}
]
},
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 ejecutor",
"timeout": 130000
}
]
}
]
}
}

View File

@@ -0,0 +1,86 @@
{
"version": 2,
"lastComputedDate": "2026-02-17",
"dailyActivity": [
{
"date": "2026-02-15",
"messageCount": 2052,
"sessionCount": 9,
"toolCallCount": 262
},
{
"date": "2026-02-16",
"messageCount": 787,
"sessionCount": 4,
"toolCallCount": 83
},
{
"date": "2026-02-17",
"messageCount": 1154,
"sessionCount": 1,
"toolCallCount": 123
}
],
"dailyModelTokens": [
{
"date": "2026-02-15",
"tokensByModel": {
"claude-opus-4-5-20251101": 3247,
"claude-opus-4-6": 81887
}
},
{
"date": "2026-02-16",
"tokensByModel": {
"claude-opus-4-6": 25122
}
},
{
"date": "2026-02-17",
"tokensByModel": {
"claude-opus-4-6": 36622
}
}
],
"modelUsage": {
"claude-opus-4-5-20251101": {
"inputTokens": 196,
"outputTokens": 3051,
"cacheReadInputTokens": 314084,
"cacheCreationInputTokens": 35936,
"webSearchRequests": 0,
"costUSD": 0,
"contextWindow": 0,
"maxOutputTokens": 0
},
"claude-opus-4-6": {
"inputTokens": 1708,
"outputTokens": 141923,
"cacheReadInputTokens": 43414737,
"cacheCreationInputTokens": 7323135,
"webSearchRequests": 0,
"costUSD": 0,
"contextWindow": 0,
"maxOutputTokens": 0
}
},
"totalSessions": 14,
"totalMessages": 3993,
"longestSession": {
"sessionId": "b1715c14-9ef8-4b54-9fda-d281c55c2a07",
"duration": 84183755,
"messageCount": 408,
"timestamp": "2026-02-16T08:26:52.205Z"
},
"firstSessionDate": "2026-02-15T00:55:54.803Z",
"hourCounts": {
"0": 2,
"2": 3,
"13": 4,
"18": 1,
"19": 1,
"20": 1,
"23": 2
},
"totalSpeculationTimeSavedMs": 0
}

9
.claude-ejecutor/ui.json Normal file
View File

@@ -0,0 +1,9 @@
{
"label": "Ejecutor",
"shortLabel": "EJ",
"color": "#ef4444",
"gradient": "linear-gradient(135deg, #ef4444, #dc2626)",
"terminalBg": "#0a0f1a",
"terminalBorder": "#ef4444",
"enabled": true
}

21
.claude-nucleo000/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Credenciales (NUNCA versionar)
.credentials.json
# Estado interno de Claude (regenerable)
.claude.json
.claude.json.backup*
# Conversaciones y logs (muy grandes, privados)
projects/**/*.jsonl
history.jsonl
debug/
# Cache y temporales
cache/
tmp/
session-env/
shell-snapshots/
todos/
# Repos externos clonados
plugins/marketplaces/*/

View File

@@ -0,0 +1,584 @@
/c/Users/jodar/agent-ui/frontend/node_modules/.vite/deps/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@apideck/better-ajv-errors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/code-frame/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/compat-data/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/generator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-annotate-as-pure/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-compilation-targets/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-create-class-features-plugin/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-create-regexp-features-plugin/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-define-polyfill-provider/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-globals/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-member-expression-to-functions/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-module-imports/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-module-transforms/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-optimise-call-expression/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-plugin-utils/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-remap-async-to-generator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-replace-supers/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-skip-transparent-expression-wrappers/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-string-parser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-validator-identifier/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-validator-option/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-wrap-function/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helpers/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/parser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-proposal-private-property-in-object/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-syntax-import-assertions/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-syntax-import-attributes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-syntax-unicode-sets-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-arrow-functions/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-async-generator-functions/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-async-to-generator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-block-scoped-functions/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-block-scoping/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-class-properties/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-class-static-block/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-classes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-computed-properties/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-destructuring/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-dotall-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-duplicate-keys/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-dynamic-import/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-explicit-resource-management/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-exponentiation-operator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-export-namespace-from/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-for-of/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-function-name/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-json-strings/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-literals/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-logical-assignment-operators/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-member-expression-literals/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-amd/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-systemjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-umd/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-named-capturing-groups-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-new-target/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-nullish-coalescing-operator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-numeric-separator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-object-rest-spread/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-object-super/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-optional-catch-binding/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-optional-chaining/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-parameters/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-private-methods/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-private-property-in-object/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-property-literals/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-regenerator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-regexp-modifiers/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-reserved-words/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-shorthand-properties/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-spread/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-sticky-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-template-literals/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-typeof-symbol/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-escapes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-property-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-sets-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/preset-env/data/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/preset-env/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/preset-modules/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/runtime/helpers/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/runtime/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/template/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/traverse/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@esbuild/win32-x64/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@hono/node-server/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@img/colour/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@img/sharp-win32-x64/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@isaacs/cliui/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@isaacs/cliui/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@isaacs/cliui/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/gen-mapping/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/remapping/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/resolve-uri/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/source-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/sourcemap-codec/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/trace-mapping/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@modelcontextprotocol/sdk/dist/cjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@modelcontextprotocol/sdk/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@modelcontextprotocol/sdk/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@nucleoriofrio/webmcp/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rolldown/pluginutils/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-babel/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/dist/es/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils/dist/es/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-replace/node_modules/magic-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-replace/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-terser/dist/es/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-terser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/dist/es/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/node_modules/@types/estree/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/node_modules/estree-walker/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/node_modules/picomatch/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/rollup-win32-x64-gnu/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/rollup-win32-x64-msvc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@surma/rollup-plugin-off-main-thread/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@types/estree/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@types/node/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@types/resolve/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@types/trusted-types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vitejs/plugin-vue/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@volar/language-core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@volar/source-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@volar/typescript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-dom/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-sfc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-ssr/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/devtools-api/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/devtools-kit/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/devtools-shared/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/language-core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/reactivity/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/runtime-core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/runtime-dom/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/server-renderer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/shared/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/tsconfig/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/addon-fit/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/addon-web-links/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/addon-webgl/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/xterm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/accepts/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/acorn/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ajv/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ajv-formats/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/alien-signals/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/array-buffer-byte-length/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/arraybuffer.prototype.slice/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/async/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/async-function/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/at-least-node/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/available-typed-arrays/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/babel-plugin-polyfill-corejs2/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/babel-plugin-polyfill-corejs3/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/babel-plugin-polyfill-regenerator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/balanced-match/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/balanced-match/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/balanced-match/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/baseline-browser-mapping/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/birpc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/body-parser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/brace-expansion/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/brace-expansion/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/brace-expansion/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/browserslist/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/buffer-from/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/bytes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/call-bind/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/call-bind-apply-helpers/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/call-bound/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/caniuse-lite/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/child_process/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/commander/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/common-tags/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/content-disposition/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/content-type/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/convert-source-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/cookie/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/cookie-signature/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/copy-anything/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/core-js-compat/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/cors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/cross-spawn/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/crypto/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/crypto-random-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/csstype/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/data-view-buffer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/data-view-byte-length/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/data-view-byte-offset/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/debug/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/deepmerge/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/define-data-property/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/define-properties/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/depd/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/detect-libc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/dotenv/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/dunder-proto/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ee-first/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ejs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/electron-to-chromium/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/encodeurl/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/entities/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/entities/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/entities/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/env-paths/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/es-abstract/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/es-define-property/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/es-errors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/es-object-atoms/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/es-set-tostringtag/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/es-to-primitive/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/esbuild/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/escalade/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/escape-html/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/estree-walker/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/estree-walker/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/estree-walker/src/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/esutils/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/etag/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/eventsource/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/eventsource-parser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/express/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/express-rate-limit/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fast-deep-equal/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fast-json-stable-stringify/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fast-uri/benchmark/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fast-uri/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fdir/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/node_modules/minimatch/node_modules/brace-expansion/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/node_modules/minimatch/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/finalhandler/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/for-each/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/foreground-child/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/foreground-child/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/foreground-child/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/forwarded/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fresh/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/fs-extra/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/function-bind/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/function.prototype.name/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/functions-have-names/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/generator-function/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/gensync/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/get-intrinsic/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/get-own-enumerable-property-symbols/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/get-proto/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/get-symbol-description/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/glob/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/glob/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/glob/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/globalthis/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/gopd/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/graceful-fs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/has-bigints/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/has-property-descriptors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/has-proto/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/has-symbols/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/has-tostringtag/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/hasown/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/hono/dist/cjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/hono/dist/types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/hono/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/hookable/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/http/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/http-errors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/iconv-lite/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/idb/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/inherits/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/internal-slot/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ip-address/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ipaddr.js/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-array-buffer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-async-function/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-bigint/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-boolean-object/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-callable/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-core-module/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-data-view/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-date-object/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-finalizationregistry/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-generator-function/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-module/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-negative-zero/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-number-object/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-obj/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-promise/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-regexp/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-set/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-shared-array-buffer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-stream/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-symbol/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-typed-array/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-weakmap/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-weakref/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-weakset/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/is-what/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/isarray/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/isexe/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jackspeak/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jackspeak/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jackspeak/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jake/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jose/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/js-tokens/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jsesc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/json-schema/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/json-schema-traverse/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/json-schema-typed/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/json5/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jsonfile/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/jsonpointer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/leven/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/lodash/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/lodash.debounce/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/lodash.sortby/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/lru-cache/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/magic-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/math-intrinsics/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/media-typer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/merge-descriptors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/mime-db/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/mime-types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/minimatch/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/minimatch/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/minimatch/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/minipass/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/minipass/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/minipass/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/mitt/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ms/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/muggle-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/async/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/non-secure/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/url-alphabet/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/negotiator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/node-releases/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/object-assign/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/object-inspect/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/object-keys/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/object.assign/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/on-finished/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/once/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/os/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/own-keys/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/package-json-from-dist/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/package-json-from-dist/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/package-json-from-dist/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/parseurl/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-browserify/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-key/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-parse/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/node_modules/lru-cache/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/node_modules/lru-cache/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/path-to-regexp/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/perfect-debounce/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/picocolors/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/picomatch/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/pinia/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/pkce-challenge/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/possible-typed-array-names/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/postcss/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/pretty-bytes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/process/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/proxy-addr/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/punycode/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/qs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/randombytes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/range-parser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/raw-body/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/reflect.getprototypeof/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/regenerate/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/regenerate-unicode-properties/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/regexp.prototype.flags/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/regexpu-core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/regjsgen/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/regjsparser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/require-from-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/module_dir/zmodules/bbb/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/baz/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/browser_field/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/dot_main/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/dot_slash_main/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/false_main/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/incorrect_main/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/invalid_main/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/multirepo/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/multirepo/packages/package-a/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/multirepo/packages/package-b/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/nested_symlinks/mylib/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/symlinked/package/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/rfdc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/rollup/dist/es/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/rollup/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/router/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/safe-array-concat/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/safe-buffer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/safe-push-apply/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/safe-regex-test/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/safer-buffer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/semver/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/send/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/serialize-javascript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/serve-static/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/set-function-length/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/set-function-name/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/set-proto/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/setprototypeof/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/sharp/node_modules/semver/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/sharp/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/shebang-command/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/shebang-regex/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel-list/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel-weakmap/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/signal-exit/dist/cjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/signal-exit/dist/mjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/signal-exit/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/smob/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/source-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/source-map-js/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/source-map-support/node_modules/source-map/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/source-map-support/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/sourcemap-codec/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/speakingurl/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/statuses/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/stop-iteration-iterator/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.matchall/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.trim/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.trimend/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.trimstart/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/stringify-object/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/strip-comments/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/superjson/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/supports-preserve-symlinks-flag/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/temp-dir/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/tempy/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/terser/bin/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/terser/dist/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/terser/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/tinyglobby/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/toidentifier/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/tr46/node_modules/punycode/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/tr46/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/type-fest/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/type-is/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-buffer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-byte-length/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-byte-offset/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-length/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/typescript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unbox-primitive/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/undici-types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-canonical-property-names-ecmascript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-match-property-ecmascript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-match-property-value-ecmascript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-property-aliases-ecmascript/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unique-string/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/universalify/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/unpipe/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/upath/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/update-browserslist-db/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/url/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/util/node_modules/inherits/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/util/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vary/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vite/node_modules/rollup/dist/es/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vite/node_modules/rollup/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vite/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vite/types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vite-plugin-pwa/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vite-plugin-pwa/types/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vscode-uri/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue/compiler-sfc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue/jsx-runtime/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue/server-renderer/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue-router/node_modules/@vue/devtools-api/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue-router/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/vue-tsc/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/webidl-conversions/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/whatwg-url/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/which/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/which-boxed-primitive/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/which-builtin-type/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/which-collection/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/which-typed-array/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-background-sync/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-broadcast-update/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-build/node_modules/pretty-bytes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-build/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-cacheable-response/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-expiration/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-google-analytics/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-navigation-preload/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-precaching/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-range-requests/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-recipes/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-routing/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-strategies/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-streams/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-sw/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-window/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/wrappy/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/ws/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/yallist/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/locales/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/mini/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v3/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/classic/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/core/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/locales/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/mini/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4-mini/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod-to-json-schema/dist/cjs/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod-to-json-schema/dist/esm/package.json
/c/Users/jodar/agent-ui/frontend/node_modules/zod-to-json-schema/package.json
/c/Users/jodar/agent-ui/frontend/package.json
/c/Users/jodar/agent-ui/node_modules/ansi-regex/package.json
/c/Users/jodar/agent-ui/node_modules/ansi-styles/package.json
/c/Users/jodar/agent-ui/node_modules/chalk/node_modules/supports-color/package.json
/c/Users/jodar/agent-ui/node_modules/chalk/package.json
/c/Users/jodar/agent-ui/node_modules/cliui/package.json
/c/Users/jodar/agent-ui/node_modules/color-convert/package.json
/c/Users/jodar/agent-ui/node_modules/color-name/package.json
/c/Users/jodar/agent-ui/node_modules/concurrently/package.json
/c/Users/jodar/agent-ui/node_modules/emoji-regex/package.json
/c/Users/jodar/agent-ui/node_modules/escalade/package.json
/c/Users/jodar/agent-ui/node_modules/get-caller-file/package.json
/c/Users/jodar/agent-ui/node_modules/has-flag/package.json
/c/Users/jodar/agent-ui/node_modules/is-fullwidth-code-point/package.json
/c/Users/jodar/agent-ui/node_modules/require-directory/package.json
/c/Users/jodar/agent-ui/node_modules/rxjs/ajax/package.json
/c/Users/jodar/agent-ui/node_modules/rxjs/fetch/package.json
/c/Users/jodar/agent-ui/node_modules/rxjs/operators/package.json
/c/Users/jodar/agent-ui/node_modules/rxjs/package.json
/c/Users/jodar/agent-ui/node_modules/rxjs/testing/package.json
/c/Users/jodar/agent-ui/node_modules/rxjs/webSocket/package.json
/c/Users/jodar/agent-ui/node_modules/shell-quote/package.json
/c/Users/jodar/agent-ui/node_modules/string-width/package.json
/c/Users/jodar/agent-ui/node_modules/strip-ansi/package.json
/c/Users/jodar/agent-ui/node_modules/supports-color/package.json
/c/Users/jodar/agent-ui/node_modules/tree-kill/package.json
/c/Users/jodar/agent-ui/node_modules/tslib/modules/package.json
/c/Users/jodar/agent-ui/node_modules/tslib/package.json
/c/Users/jodar/agent-ui/node_modules/wrap-ansi/package.json
/c/Users/jodar/agent-ui/node_modules/y18n/package.json
/c/Users/jodar/agent-ui/node_modules/yargs/helpers/package.json
/c/Users/jodar/agent-ui/node_modules/yargs/package.json
/c/Users/jodar/agent-ui/node_modules/yargs-parser/package.json
/c/Users/jodar/agent-ui/package.json
/c/Users/jodar/agent-ui/server/node_modules/@skitee3000/bun-pty/package.json
/c/Users/jodar/agent-ui/server/node_modules/node-addon-api/package.json
/c/Users/jodar/agent-ui/server/package.json

View File

@@ -0,0 +1,134 @@
{
"env": {
"DISABLE_TELEMETRY": "1"
},
"permissions": {
"allow": [],
"deny": []
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"PostToolUseFailure": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"Notification": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
}
],
"PermissionRequest": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
},
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 nucleo000",
"timeout": 130000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 10000
}
]
},
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 nucleo000",
"timeout": 130000
}
]
}
]
}
}

View File

@@ -45,11 +45,180 @@
"mcp__agent-ui__localhost_4100-terminal_move",
"mcp__agent-ui__localhost_4100-terminal_resize",
"mcp__agent-ui__localhost_4100-terminal_toggle",
"mcp__agent-ui__localhost_4100-terminal_close"
"mcp__agent-ui__localhost_4100-terminal_close",
"mcp__agent-ui__localhost_4100-bubbleResponse",
"mcp__agent-ui__localhost_4100-notificar",
"mcp__agent-ui__localhost_4100-enviar_al_panel",
"mcp__agent-ui__localhost_4100-render_html",
"mcp__agent-ui__localhost_4100-load_vue_component",
"mcp__agent-ui__localhost_4100-page_refresh",
"WebFetch(domain:docs.anthropic.com)",
"mcp__agent-ui__z590_nucleoriofrio_com-bubbleResponse",
"Bash(git add:*)",
"Bash(git commit:*)",
"mcp__agent-ui__z590_nucleoriofrio_com-navigate_to",
"mcp__agent-ui__z590_nucleoriofrio_com-activate_tool",
"mcp__agent-ui__z590_nucleoriofrio_com-list_available_tools",
"mcp__agent-ui__z590_nucleoriofrio_com-page_refresh",
"mcp__agent-ui__z590_nucleoriofrio_com-render_html",
"mcp__agent-ui__z590_nucleoriofrio_com-render_vue_component",
"mcp__agent-ui__z590_nucleoriofrio_com-pin_tool",
"mcp__agent-ui__z590_nucleoriofrio_com-list_torch_clients",
"mcp__agent-ui__z590_nucleoriofrio_com-transfer_torch",
"mcp__agent-ui__z590_nucleoriofrio_com-get_current_page",
"mcp__agent-ui__z590_nucleoriofrio_com-list_windows",
"mcp__agent-ui__z590_nucleoriofrio_com-move_window",
"mcp__agent-ui__z590_nucleoriofrio_com-close_window",
"mcp__agent-ui__z590_nucleoriofrio_com-get_canvas_css",
"mcp__agent-ui__z590_nucleoriofrio_com-inspect_window",
"mcp__agent-ui__z590_nucleoriofrio_com-get_canvas",
"mcp__agent-ui__z590_nucleoriofrio_com-canvas_js",
"mcp__agent-ui__z590_nucleoriofrio_com-canvas_css",
"mcp__agent-ui__z590_nucleoriofrio_com-edit_canvas",
"mcp__agent-ui__z590_nucleoriofrio_com-load_vue_component",
"mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component",
"mcp__agent-ui__z590_nucleoriofrio_com-resize_window",
"mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot",
"mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot",
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvas_snapshots",
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvases",
"mcp__agent-ui__z590_nucleoriofrio_com-list_vue_components",
"Bash(jq:*)",
"mcp__agent-ui__z590_nucleoriofrio_com-read_component",
"mcp__agent-ui__z590_nucleoriofrio_com-edit_component",
"mcp__agent-ui__z590_nucleoriofrio_com-list_fs_components",
"mcp__agent-ui__z590_nucleoriofrio_com-load_fs_component",
"Bash(grep:*)",
"WebFetch(domain:v2.tauri.app)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"agent-ui"
]
],
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"PostToolUseFailure": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"Notification": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"PermissionRequest": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
},
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1",
"timeout": 130000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 10000
}
]
},
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1",
"timeout": 130000
}
]
}
]
}
}

9
.claude/ui.json Normal file
View File

@@ -0,0 +1,9 @@
{
"label": "Main",
"shortLabel": "M",
"color": "#6366f1",
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
"terminalBg": "#0f0a1a",
"terminalBorder": "#6366f1",
"enabled": true
}

59
.gitignore vendored
View File

@@ -3,3 +3,62 @@ frontend/node_modules/
.env
*.log
dist/
frontend/dev-dist/
nul
# Voice recordings (training data)
server/recordings/*.webm
# Installers / APKs
installers/
# Tauri build artifacts
src-tauri/target/
src-tauri/installers/
# Tauri gen: ignore everything except our custom Android sources
src-tauri/gen/*
!src-tauri/gen/android/
src-tauri/gen/android/*
!src-tauri/gen/android/app/
src-tauri/gen/android/app/*
!src-tauri/gen/android/app/build.gradle.kts
!src-tauri/gen/android/app/src/
src-tauri/gen/android/app/src/*
!src-tauri/gen/android/app/src/main/
src-tauri/gen/android/app/src/main/*
!src-tauri/gen/android/app/src/main/AndroidManifest.xml
!src-tauri/gen/android/app/src/main/java/
!src-tauri/gen/android/app/src/main/res/
src-tauri/gen/android/app/src/main/res/*
!src-tauri/gen/android/app/src/main/res/layout/
src-tauri/gen/android/app/src/main/res/layout/*
!src-tauri/gen/android/app/src/main/res/layout/widget_transcript.xml
!src-tauri/gen/android/app/src/main/res/layout/widget_terminal_item.xml
!src-tauri/gen/android/app/src/main/res/layout/face_widget_lockscreen.xml
!src-tauri/gen/android/app/src/main/res/layout/face_widget_aod.xml
!src-tauri/gen/android/app/src/main/res/xml/
src-tauri/gen/android/app/src/main/res/xml/*
!src-tauri/gen/android/app/src/main/res/xml/transcript_widget_info.xml
!src-tauri/gen/android/app/src/main/res/xml/voice_interaction_service.xml
!src-tauri/gen/android/app/src/main/res/xml/recognition_service.xml
!src-tauri/gen/android/app/src/main/res/values/
src-tauri/gen/android/app/src/main/res/values/*
!src-tauri/gen/android/app/src/main/res/values/strings.xml
!src-tauri/gen/android/app/src/main/res/raw/
src-tauri/gen/android/app/src/main/res/raw/*
!src-tauri/gen/android/app/src/main/res/raw/facewidgets.json
!src-tauri/gen/android/app/src/main/res/drawable/
src-tauri/gen/android/app/src/main/res/drawable/*
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_dark.xml
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_aod.xml
src-tauri/gen/android/keystore.jks
# Old frontend Tauri location
frontend/src-tauri/
# Agent runtime data
.claude-*/plugins/
.claude-*/plans/
.claude-*/file-history/
.claude-*/tasks/

View File

@@ -6,7 +6,6 @@
"args": [
"git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
"--mcp",
"--dev",
"-p", "4102"
]
}

View File

@@ -2,6 +2,50 @@
Dynamic canvas interface for Claude Code interaction via MCP (Model Context Protocol).
---
## Nucleo
<p align="center">
<img src="docs/nucleo-logo.svg" alt="Nucleo" width="120"/>
</p>
**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.

61
docs/nucleo-logo.svg Normal file
View File

@@ -0,0 +1,61 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="coreGradient" cx="50%" cy="30%" r="60%">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="50%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#6366f1"/>
</radialGradient>
<linearGradient id="orbitGradient1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818cf8" stop-opacity="0.9"/>
<stop offset="50%" stop-color="#a78bfa" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.9"/>
</linearGradient>
<linearGradient id="electronGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e0e7ff"/>
<stop offset="100%" stop-color="#a78bfa"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="coreGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background circle (subtle) -->
<circle cx="60" cy="60" r="55" fill="none" stroke="#4f46e5" stroke-width="1" opacity="0.2"/>
<!-- Orbital rings -->
<ellipse cx="60" cy="60" rx="45" ry="18" stroke="url(#orbitGradient1)" stroke-width="2" fill="none" transform="rotate(-30 60 60)" opacity="0.7"/>
<ellipse cx="60" cy="60" rx="45" ry="18" stroke="url(#orbitGradient1)" stroke-width="2" fill="none" transform="rotate(30 60 60)" opacity="0.5"/>
<ellipse cx="60" cy="60" rx="45" ry="18" stroke="url(#orbitGradient1)" stroke-width="2" fill="none" transform="rotate(90 60 60)" opacity="0.6"/>
<!-- Core nucleus with glow -->
<circle cx="60" cy="60" r="18" fill="url(#coreGradient)" filter="url(#coreGlow)"/>
<circle cx="60" cy="60" r="14" fill="url(#coreGradient)" opacity="0.8"/>
<circle cx="55" cy="55" r="5" fill="white" opacity="0.4"/>
<!-- Electrons with glow -->
<circle cx="60" cy="15" r="7" fill="url(#electronGradient)" filter="url(#glow)">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="4s" repeatCount="indefinite"/>
</circle>
<circle cx="100" cy="70" r="6" fill="url(#electronGradient)" filter="url(#glow)">
<animateTransform attributeName="transform" type="rotate" from="120 60 60" to="480 60 60" dur="5s" repeatCount="indefinite"/>
</circle>
<circle cx="20" cy="70" r="6" fill="url(#electronGradient)" filter="url(#glow)">
<animateTransform attributeName="transform" type="rotate" from="240 60 60" to="600 60 60" dur="4.5s" repeatCount="indefinite"/>
</circle>
<!-- Small accent electrons -->
<circle cx="60" cy="105" r="4" fill="#c4b5fd" opacity="0.6">
<animateTransform attributeName="transform" type="rotate" from="180 60 60" to="540 60 60" dur="6s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

@@ -1,94 +0,0 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "suppress-warnings.js",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
}, {
"url": "index.html",
"revision": "0.24e3u5ntq78"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/api\//]
}));
}));
//# sourceMappingURL=sw.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#16161d" />
<meta name="theme-color" content="#0f0f14" />
<meta name="description" content="Dynamic canvas for Claude Code interaction via WebMCP" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />

View File

@@ -9,8 +9,16 @@
"version": "0.0.0",
"dependencies": {
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"pinia": "^3.0.4",
"vite-plugin-pwa": "^1.2.0",
@@ -18,6 +26,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
@@ -1915,7 +1924,7 @@
},
"node_modules/@nucleoriofrio/webmcp": {
"version": "0.2.0",
"resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#cec5be355d67e0cf9049380ece74e9eac0e85f5e",
"resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#870207f15199369bc262d27ce0f90a27f2854be4",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
@@ -2414,6 +2423,287 @@
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-clipboard-manager": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz",
"integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
"integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"license": "MIT"
@@ -2624,6 +2914,12 @@
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",

View File

@@ -8,12 +8,29 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
"generate-icons": "node scripts/generate-icons.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:android:init": "tauri android init",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build",
"tauri:ios:init": "tauri ios init",
"tauri:ios:dev": "tauri ios dev",
"tauri:ios:build": "tauri ios build"
},
"dependencies": {
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"pinia": "^3.0.4",
"vite-plugin-pwa": "^1.2.0",
@@ -21,6 +38,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: transparent; overflow: hidden; }
body { display: flex; align-items: center; justify-content: center; }
.wrap { position: relative; width: 36px; height: 36px; }
.dot {
width: 36px; height: 36px;
border: 2.5px solid rgba(255,255,255,0.06);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.6s linear infinite;
filter: drop-shadow(0 0 8px rgba(99,102,241,0.5));
}
.badge {
display: none;
position: absolute; top: -4px; right: -6px;
background: #ef4444; color: #fff;
font: 700 9px/1 -apple-system, sans-serif;
min-width: 14px; height: 14px;
border-radius: 7px;
text-align: center;
padding: 2px 3px;
}
.badge.show { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="wrap">
<div class="dot"></div>
<div class="badge" id="b"></div>
</div>
<script>
var badge = document.getElementById('b');
function updateBadge(n) {
if (n > 1) { badge.textContent = n; badge.className = 'badge show'; }
else { badge.className = 'badge'; }
}
// Initial count from query param
var c = new URLSearchParams(location.search).get('n');
if (c) updateBadge(parseInt(c));
// Live updates via Tauri event (no module imports needed)
var T = window.__TAURI_INTERNALS__;
if (T) {
var handler = T.transformCallback(function(ev) { updateBadge(ev.payload); });
T.invoke('plugin:event|listen', { event: 'loading:count', target: { kind: 'Any' }, handler: handler });
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { renderInlineComponent, type VueComponentDefinition } from '../services/dynamicComponents'
import { useCanvasStore } from '../stores/canvas'
import CanvasGallery from './CanvasGallery.vue'
const canvasStore = useCanvasStore()
const showGallery = ref(true)
function handleLoadComponent(e: Event) {
const detail = (e as CustomEvent).detail
@@ -12,6 +14,9 @@ function handleLoadComponent(e: Event) {
const container = document.getElementById('canvas-content')
if (!container) return
// Hide gallery when MCP renders content
showGallery.value = false
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
@@ -32,26 +37,49 @@ function handleLoadComponent(e: Event) {
canvasStore.addToHistory({ tool: 'load_vue_component', args: detail, timestamp: Date.now() })
}
function handleContentRendered() {
showGallery.value = false
canvasStore.isAnonymousCanvas = false
}
function handleStartAnonymous() {
showGallery.value = false
canvasStore.isAnonymousCanvas = true
}
function handleClearCanvas() {
showGallery.value = true
canvasStore.isAnonymousCanvas = false
const container = document.getElementById('canvas-content')
if (container) {
// Remove all non-gallery content
const children = Array.from(container.children)
for (const child of children) {
if (!(child as HTMLElement).classList?.contains('canvas-placeholder')) {
child.remove()
}
}
}
}
onMounted(() => {
window.addEventListener('load-vue-component', handleLoadComponent)
window.addEventListener('clear-canvas', handleClearCanvas)
window.addEventListener('canvas-content-rendered', handleContentRendered)
})
onUnmounted(() => {
window.removeEventListener('load-vue-component', handleLoadComponent)
window.removeEventListener('clear-canvas', handleClearCanvas)
window.removeEventListener('canvas-content-rendered', handleContentRendered)
})
</script>
<template>
<div class="canvas-container">
<div id="canvas-content" class="canvas-content">
<div class="canvas-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
<p>Canvas listo</p>
<span>Haz clic en el cuadrado azul (abajo derecha) para conectar con Claude Code</span>
<div v-if="showGallery" class="canvas-placeholder">
<CanvasGallery @snapshot-restored="showGallery = false" @component-loaded="showGallery = false" @start-anonymous="handleStartAnonymous" />
</div>
</div>
</div>
@@ -63,38 +91,19 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow: auto;
overflow: hidden;
position: relative;
}
.canvas-content {
flex: 1;
padding: 1.5rem;
position: relative;
min-height: 100%;
overflow: auto;
}
.canvas-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
color: var(--text-muted);
text-align: center;
}
.canvas-placeholder svg {
margin-bottom: 1rem;
opacity: 0.5;
}
.canvas-placeholder p {
font-size: 1.25rem;
margin: 0 0 0.5rem 0;
}
.canvas-placeholder span {
font-size: 0.875rem;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,980 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectCanvasStore } from '../stores/projectCanvas'
import { useSnapshotsStore, type SnapshotSummary } from '../stores/snapshots'
import {
componentsApi,
renderInlineComponent,
type VueComponentDefinition
} from '../services/dynamicComponents'
import { getWindowDefinitions } from '../services/tools/handlers/canvasHandlers'
import { useCanvasStore } from '../stores/canvas'
const emit = defineEmits<{
(e: 'snapshot-restored'): void
(e: 'component-loaded'): void
(e: 'start-anonymous'): void
}>()
const router = useRouter()
const store = useProjectCanvasStore()
const snapshotsStore = useSnapshotsStore()
const canvasStore = useCanvasStore()
const showArchived = ref(false)
const searchQuery = ref('')
const settingsOpenId = ref<string | null>(null)
const restoringSnapshot = ref<string | null>(null)
const showNewForm = ref(false)
const newName = ref('')
const creating = ref(false)
const savedComponents = ref<VueComponentDefinition[]>([])
const loadingComponent = ref<string | null>(null)
// Editable settings state
const editIcon = ref('')
const editOrder = ref(99)
const filteredCanvases = computed(() => {
let list = showArchived.value ? store.canvases : store.activeCanvasesList
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
list = list.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.description && c.description.toLowerCase().includes(q))
)
}
return list.slice(0, 10)
})
const filteredSnapshots = computed(() => {
if (!searchQuery.value) return snapshotsStore.snapshots
const q = searchQuery.value.toLowerCase()
return snapshotsStore.snapshots.filter(s => s.name.toLowerCase().includes(q))
})
const filteredComponents = computed(() => {
if (!searchQuery.value) return savedComponents.value
const q = searchQuery.value.toLowerCase()
return savedComponents.value.filter(c => c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q))
})
const totalItems = computed(() => store.canvases.length + snapshotsStore.snapshots.length + savedComponents.value.length)
const showSearch = computed(() => totalItems.value > 8)
function navigateToCanvas(id: string) {
router.push(`/canvas/${id}`)
}
async function createNewCanvas() {
const name = newName.value.trim()
if (!name) return
creating.value = true
try {
const id = await store.createCanvas({ name, type: 'project' } as any)
if (id) {
newName.value = ''
showNewForm.value = false
router.push(`/canvas/${id}`)
}
} finally {
creating.value = false
}
}
async function loadSnapshot(snap: SnapshotSummary) {
restoringSnapshot.value = snap.id
try {
await snapshotsStore.restore(snap.id)
emit('snapshot-restored')
} finally {
restoringSnapshot.value = null
}
}
function formatDate(timestamp: number) {
return new Date(timestamp).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})
}
function toggleSettings(id: string) {
if (settingsOpenId.value === id) {
settingsOpenId.value = null
} else {
settingsOpenId.value = id
const canvas = store.canvases.find(c => c.id === id)
if (canvas) {
editIcon.value = canvas.toolbar_icon || ''
editOrder.value = canvas.toolbar_order ?? 99
}
}
}
async function toggleToolbar(id: string, current: boolean) {
await store.updateCanvas(id, { show_in_toolbar: !current } as any)
await store.fetchToolbarCanvases()
}
async function updateIcon(id: string) {
await store.updateCanvas(id, { toolbar_icon: editIcon.value || null } as any)
await store.fetchToolbarCanvases()
}
async function updateOrder(id: string) {
await store.updateCanvas(id, { toolbar_order: editOrder.value } as any)
await store.fetchToolbarCanvases()
}
async function archiveCanvas(id: string) {
await store.deleteCanvas(id)
await store.fetchCanvases(showArchived.value)
await store.fetchToolbarCanvases()
settingsOpenId.value = null
}
async function restoreCanvas(id: string) {
await store.restoreCanvas(id)
await store.fetchCanvases(showArchived.value)
settingsOpenId.value = null
}
async function loadComponent(comp: VueComponentDefinition) {
loadingComponent.value = comp.id
try {
const container = document.getElementById('canvas-content')
if (!container) return
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
const result = renderInlineComponent(comp, container, {}, true)
getWindowDefinitions().set(comp.id, {
source: 'db',
componentId: comp.id,
definition: comp,
componentProps: {}
})
;(window as any).__vueComponentUnmount = result.unmount
canvasStore.addToHistory({ tool: 'load_vue_component', args: { id: comp.id }, timestamp: Date.now() })
emit('component-loaded')
} finally {
loadingComponent.value = null
}
}
async function fetchComponents() {
try {
savedComponents.value = await componentsApi.getAll({ limit: 10 })
} catch {
savedComponents.value = []
}
}
async function deleteSnapshot(id: string) {
await snapshotsStore.remove(id)
}
function toggleArchived() {
showArchived.value = !showArchived.value
store.fetchCanvases(showArchived.value)
}
onMounted(() => {
store.fetchCanvases(false)
snapshotsStore.list()
fetchComponents()
})
</script>
<template>
<div class="canvas-gallery">
<div class="gallery-header">
<h2>Canvases</h2>
<div class="header-actions">
<button
class="toggle-archived"
:class="{ active: showArchived }"
@click="toggleArchived"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="21 8 21 21 3 21 3 8"/>
<rect x="1" y="3" width="22" height="5"/>
<line x1="10" y1="12" x2="14" y2="12"/>
</svg>
Archivados
</button>
<input
v-if="showSearch"
v-model="searchQuery"
type="text"
placeholder="Buscar..."
class="search-input"
/>
</div>
</div>
<div v-if="store.loading" class="gallery-loading">
<div class="spinner"></div>
</div>
<template v-else>
<!-- Project Canvases -->
<div class="gallery-grid">
<!-- Anonymous dynamic canvas card -->
<div class="canvas-card new-card anon-card" @click="emit('start-anonymous')">
<div class="card-icon new-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
</svg>
</div>
<div class="card-content">
<div class="card-name">Dynamic Canvas</div>
</div>
</div>
<!-- New canvas card -->
<div class="canvas-card new-card" @click="showNewForm = true" v-if="!showNewForm">
<div class="card-icon new-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
</div>
<div class="card-content">
<div class="card-name">Nuevo canvas</div>
</div>
</div>
<!-- New canvas form (inline) -->
<div class="canvas-card new-form-card" v-if="showNewForm" @click.stop>
<input
v-model="newName"
type="text"
class="new-canvas-input"
placeholder="Nombre del canvas..."
autofocus
@keyup.enter="createNewCanvas"
@keyup.escape="showNewForm = false; newName = ''"
/>
<div class="new-form-actions">
<button class="new-btn create" :disabled="!newName.trim() || creating" @click="createNewCanvas">
{{ creating ? '...' : 'Crear' }}
</button>
<button class="new-btn cancel" @click="showNewForm = false; newName = ''">Cancelar</button>
</div>
</div>
<div
v-for="canvas in filteredCanvases"
:key="canvas.id"
class="canvas-card"
:class="{ archived: canvas.status === 'archived' }"
@click="navigateToCanvas(canvas.id)"
>
<div class="card-icon">
<span v-if="canvas.toolbar_icon" class="card-emoji">{{ canvas.toolbar_icon }}</span>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
</div>
<div class="card-content">
<div class="card-name">{{ canvas.name }}</div>
<div v-if="canvas.description" class="card-desc">{{ canvas.description }}</div>
</div>
<div class="card-meta">
<span class="card-badge" :class="canvas.type">{{ canvas.type }}</span>
<span v-if="canvas.status === 'archived'" class="card-badge archived-badge">Archivado</span>
</div>
<button
class="card-settings-btn"
@click.stop="toggleSettings(canvas.id)"
title="Configurar"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Settings popover -->
<div v-if="settingsOpenId === canvas.id" class="settings-popover" @click.stop>
<div class="settings-row">
<label>En toolbar</label>
<button
class="switch-btn"
:class="{ on: canvas.show_in_toolbar }"
@click="toggleToolbar(canvas.id, canvas.show_in_toolbar)"
>
<span class="switch-knob"></span>
</button>
</div>
<div class="settings-row">
<label>Icono</label>
<input
v-model="editIcon"
type="text"
class="settings-input icon-input"
placeholder="emoji"
maxlength="2"
@change="updateIcon(canvas.id)"
/>
</div>
<div class="settings-row">
<label>Orden</label>
<input
v-model.number="editOrder"
type="number"
class="settings-input order-input"
min="0"
max="999"
@change="updateOrder(canvas.id)"
/>
</div>
<div class="settings-divider"></div>
<button
v-if="canvas.status !== 'archived'"
class="settings-action archive-btn"
@click="archiveCanvas(canvas.id)"
:disabled="canvas.is_system"
>
Archivar
</button>
<button
v-else
class="settings-action restore-btn"
@click="restoreCanvas(canvas.id)"
>
Restaurar
</button>
</div>
</div>
</div>
<!-- Snapshots section -->
<template v-if="filteredSnapshots.length > 0">
<h3 class="section-title">Snapshots</h3>
<div class="gallery-grid">
<div
v-for="snap in filteredSnapshots"
:key="snap.id"
class="canvas-card snapshot-card"
@click="loadSnapshot(snap)"
>
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</div>
<div class="card-content">
<div class="card-name">{{ snap.name }}</div>
<div class="card-desc">{{ formatDate(snap.created_at) }}</div>
</div>
<div class="card-meta">
<span class="card-badge snapshot">snapshot</span>
</div>
<!-- Loading overlay -->
<div v-if="restoringSnapshot === snap.id" class="card-loading">
<div class="spinner-sm"></div>
</div>
<button
class="card-delete-btn"
@click.stop="deleteSnapshot(snap.id)"
title="Eliminar snapshot"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Components section -->
<template v-if="filteredComponents.length > 0">
<h3 class="section-title">Componentes</h3>
<div class="gallery-grid">
<div
v-for="comp in filteredComponents"
:key="comp.id"
class="canvas-card component-card"
@click="loadComponent(comp)"
>
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
</div>
<div class="card-content">
<div class="card-name">{{ comp.name }}</div>
<div class="card-desc card-id">{{ comp.id }}</div>
<div v-if="comp.tags?.length" class="card-tags">
<span v-for="tag in comp.tags" :key="tag" class="tag-pill">{{ tag }}</span>
</div>
</div>
<div class="card-meta">
<span class="card-badge component">componente</span>
<span v-if="comp.status === 'archived'" class="card-badge archived-badge">Archivado</span>
</div>
<!-- Loading overlay -->
<div v-if="loadingComponent === comp.id" class="card-loading">
<div class="spinner-sm"></div>
</div>
</div>
</div>
</template>
<!-- Empty state -->
<div v-if="filteredCanvases.length === 0 && filteredSnapshots.length === 0 && filteredComponents.length === 0" class="gallery-empty">
<p v-if="showArchived && store.archivedCanvases.length === 0">No hay canvases archivados</p>
<p v-else-if="searchQuery">Sin resultados para "{{ searchQuery }}"</p>
<p v-else>No hay canvases, snapshots ni componentes guardados</p>
</div>
</template>
</div>
</template>
<style scoped>
.canvas-gallery {
display: flex;
flex-direction: column;
height: 100%;
padding: 2rem;
overflow-y: auto;
}
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.gallery-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
margin: 1.5rem 0 0.75rem 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toggle-archived {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s ease;
}
.toggle-archived:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.toggle-archived.active {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.3);
color: #6366f1;
}
.search-input {
padding: 0.375rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.8125rem;
outline: none;
width: 180px;
transition: border-color 0.15s ease;
}
.search-input:focus {
border-color: #6366f1;
}
.gallery-loading {
display: flex;
justify-content: center;
padding: 3rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner-sm {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.gallery-empty {
display: flex;
justify-content: center;
padding: 3rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.canvas-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.canvas-card:hover {
border-color: rgba(99, 102, 241, 0.4);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.canvas-card.archived {
opacity: 0.55;
}
.canvas-card.archived:hover {
opacity: 0.8;
}
.snapshot-card:hover {
border-color: rgba(168, 85, 247, 0.4);
}
.component-card:hover {
border-color: rgba(6, 182, 212, 0.4);
}
.card-id {
font-family: monospace;
font-size: 0.75rem;
opacity: 0.6;
}
.card-icon {
color: var(--text-muted);
}
.card-emoji {
font-size: 1.75rem;
line-height: 1;
}
.card-content {
flex: 1;
min-width: 0;
}
.card-name {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.card-badge {
padding: 0.125rem 0.5rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
}
.card-badge.system {
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
}
.card-badge.project {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.card-badge.dynamic {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.card-badge.snapshot {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
}
.card-badge.component {
background: rgba(6, 182, 212, 0.15);
color: #06b6d4;
}
.card-badge.archived-badge {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.card-settings-btn,
.card-delete-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
}
.canvas-card:hover .card-settings-btn,
.canvas-card:hover .card-delete-btn {
opacity: 1;
}
.card-settings-btn:hover,
.card-delete-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.card-delete-btn:hover {
color: #ef4444;
}
.card-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
border-radius: 10px;
z-index: 5;
}
/* Settings popover */
.settings-popover {
position: absolute;
top: 2.5rem;
right: 0.75rem;
width: 200px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.settings-row label {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 500;
}
.settings-input {
padding: 0.25rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.75rem;
outline: none;
}
.settings-input:focus {
border-color: #6366f1;
}
.icon-input {
width: 48px;
text-align: center;
font-size: 1rem;
}
.order-input {
width: 56px;
text-align: center;
}
.switch-btn {
position: relative;
width: 36px;
height: 20px;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
}
.switch-btn.on {
background: #6366f1;
border-color: #6366f1;
}
.switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.switch-btn.on .switch-knob {
transform: translateX(16px);
}
.settings-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
.settings-action {
width: 100%;
padding: 0.375rem;
border: none;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.archive-btn {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.archive-btn:hover {
background: rgba(239, 68, 68, 0.2);
}
.archive-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.restore-btn {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.restore-btn:hover {
background: rgba(16, 185, 129, 0.2);
}
/* New canvas card */
.new-card {
border-style: dashed;
border-color: var(--border-color);
align-items: center;
justify-content: center;
min-height: 120px;
}
.new-card:hover {
border-color: rgba(99, 102, 241, 0.5);
background: rgba(99, 102, 241, 0.05);
}
.new-card .card-content {
text-align: center;
}
.new-icon {
color: var(--text-muted);
opacity: 0.6;
}
.new-card:hover .new-icon {
opacity: 1;
color: #6366f1;
}
.new-form-card {
border-color: #6366f1;
gap: 0.75rem;
justify-content: center;
min-height: 120px;
}
.new-canvas-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.875rem;
outline: none;
}
.new-canvas-input:focus {
border-color: #6366f1;
}
.new-form-actions {
display: flex;
gap: 0.5rem;
}
.new-btn {
flex: 1;
padding: 0.375rem 0.75rem;
border: none;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.new-btn.create {
background: #6366f1;
color: white;
}
.new-btn.create:hover {
background: #4f46e5;
}
.new-btn.create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.new-btn.cancel {
background: var(--bg-hover);
color: var(--text-secondary);
}
.new-btn.cancel:hover {
background: var(--border-color);
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.375rem;
}
.tag-pill {
padding: 0.0625rem 0.375rem;
background: rgba(99, 102, 241, 0.1);
color: #818cf8;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 500;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useCanvasStore } from '../stores/canvas'
import { connectWithToken, getConnectionInfo } from '../services/webmcp'
@@ -10,6 +10,18 @@ const { isConnected, isReconnecting, connectionStatus, connectionInfo } = storeT
const isOpen = ref(false)
const tokenInput = ref('')
const isConnecting = ref(false)
const justConnected = ref(false)
// Watch for connection changes and trigger animation
watch(isConnected, (newValue, oldValue) => {
if (newValue && !oldValue) {
// Just connected - trigger animation
justConnected.value = true
setTimeout(() => {
justConnected.value = false
}, 2500) // Animation duration
}
})
const statusText = computed(() => {
if (isReconnecting.value) return 'Reconnecting...'
@@ -73,7 +85,7 @@ onUnmounted(() => {
<template>
<div class="connection-dropdown-container">
<button class="dropdown-trigger" @click.stop="toggleDropdown" title="WebMCP Connection">
<button class="dropdown-trigger" :class="{ 'just-connected': justConnected }" @click.stop="toggleDropdown" title="WebMCP Connection">
<span class="status-dot" :class="statusClass"></span>
<span>MCP</span>
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -141,6 +153,7 @@ onUnmounted(() => {
<style scoped>
.connection-dropdown-container {
position: relative;
overflow: visible;
}
.dropdown-trigger {
@@ -360,4 +373,39 @@ onUnmounted(() => {
text-align: center;
padding: 0.5rem 0;
}
/* Connection animation - Smooth effect */
.dropdown-trigger.just-connected {
animation: connectionPulse 2s ease-in-out;
}
@keyframes connectionPulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
border-color: var(--border-color);
}
15% {
transform: scale(1.05);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4),
0 0 20px rgba(16, 185, 129, 0.2);
border-color: #10b981;
}
30% {
transform: scale(1);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.5),
0 0 30px rgba(16, 185, 129, 0.25);
border-color: #34d399;
}
50% {
box-shadow: 0 0 15px rgba(16, 185, 129, 0.4),
0 0 35px rgba(16, 185, 129, 0.2);
border-color: #10b981;
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
border-color: var(--border-color);
}
}
</style>

View File

@@ -0,0 +1,352 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
export interface ResponseMessage {
id: string
message: string
type: 'info' | 'success' | 'warning' | 'error'
timestamp: number
}
const messages = ref<ResponseMessage[]>([])
const isVisible = computed(() => messages.value.length > 0)
function addMessage(message: string, type: ResponseMessage['type'] = 'info') {
const id = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
messages.value.push({
id,
message,
type,
timestamp: Date.now()
})
// No auto-dismiss - user must dismiss each message manually
return id
}
function removeMessage(id: string) {
const index = messages.value.findIndex(m => m.id === id)
if (index !== -1) {
messages.value.splice(index, 1)
}
}
function clearAll() {
messages.value = []
}
function getTypeIcon(type: ResponseMessage['type']) {
switch (type) {
case 'success': return '✓'
case 'warning': return '⚠'
case 'error': return '✕'
default: return ''
}
}
function getTypeColor(type: ResponseMessage['type']) {
switch (type) {
case 'success': return '#10b981'
case 'warning': return '#f59e0b'
case 'error': return '#ef4444'
default: return '#6366f1'
}
}
// Expose controls for MCP tools
defineExpose({
addMessage,
removeMessage,
clearAll,
getMessages: () => messages.value
})
</script>
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop-fade">
<div v-if="isVisible" class="response-backdrop" @click="clearAll"></div>
</Transition>
<Transition name="bubble-slide">
<div
v-if="isVisible"
class="floating-response"
>
<div class="response-glass">
<!-- Header -->
<div class="response-header">
<div class="header-left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Agent Response</span>
<span class="message-count">{{ messages.length }}</span>
</div>
<button class="close-btn" @click="clearAll" title="Dismiss all">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
<!-- Messages -->
<div class="messages-container">
<TransitionGroup name="message">
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
:style="{ '--type-color': getTypeColor(msg.type) }"
>
<span class="type-icon" :class="msg.type">{{ getTypeIcon(msg.type) }}</span>
<span class="message-text">{{ msg.message }}</span>
<button class="dismiss-btn" @click="removeMessage(msg.id)" title="Dismiss">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
</TransitionGroup>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* Backdrop */
.response-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10009;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.backdrop-fade-enter-active,
.backdrop-fade-leave-active {
transition: opacity 0.2s ease;
}
.backdrop-fade-enter-from,
.backdrop-fade-leave-to {
opacity: 0;
}
.floating-response {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 540px;
max-width: 92vw;
max-height: 80vh;
z-index: 10010;
}
.response-glass {
display: flex;
flex-direction: column;
max-height: 80vh;
background: rgba(200, 215, 235, 0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow:
0 0 0 1px rgba(80, 120, 180, 0.25),
0 12px 40px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.response-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
user-select: none;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
color: #222;
font: 600 13px/1 system-ui, sans-serif;
}
.header-left svg {
opacity: 0.8;
}
.message-count {
background: rgba(99, 102, 241, 0.8);
color: white;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.close-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.4);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
color: #555;
cursor: pointer;
transition: all 0.15s ease;
}
.close-btn:hover {
background: linear-gradient(180deg, #e66 0%, #c33 100%);
border-color: #a22;
color: #fff;
}
.messages-container {
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
min-height: 100px;
}
.message-item {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 20px;
background: rgba(255, 255, 255, 0.7);
border-radius: 12px;
border-left: 5px solid var(--type-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.15s ease;
}
.message-item:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.type-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 16px;
font-weight: 700;
color: white;
background: var(--type-color);
margin-top: 2px;
}
.message-text {
flex: 1;
font-size: 18px;
line-height: 1.6;
color: #111;
word-break: break-word;
font-weight: 500;
letter-spacing: -0.01em;
}
.dismiss-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
color: #666;
cursor: pointer;
transition: all 0.15s ease;
/* Prevent text selection */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.dismiss-btn:hover {
background: linear-gradient(180deg, #e66 0%, #c33 100%);
border-color: #a22;
color: #fff;
}
/* Scrollbar */
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Animations */
.bubble-slide-enter-active,
.bubble-slide-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.bubble-slide-enter-from,
.bubble-slide-leave-to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
.message-enter-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.message-leave-active {
transition: all 0.2s ease;
}
.message-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.message-leave-to {
opacity: 0;
transform: scale(0.9);
}
</style>

View File

@@ -1,583 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const terminalContainer = ref<HTMLElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const connected = ref(false)
const connecting = ref(false)
const sessionId = ref<string | null>(null)
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 580, h: 360 })
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
const WS_URL = `ws://${window.location.hostname}:4103`
// Mouse position tracking for Ctrl+E
const mousePos = ref({ x: 0, y: 0 })
let lastToggle = 0
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
function toggleTerminal() {
const now = Date.now()
if (now - lastToggle < 150) return // Debounce 150ms
lastToggle = now
if (!isOpen.value) {
// Open at mouse position (allow 75% occlusion)
const w = size.value.w
const h = size.value.h
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(mousePos.value.x - w / 2, maxX)),
y: Math.max(minY, Math.min(mousePos.value.y - h / 2, maxY))
}
hasCustomPosition.value = true
isOpen.value = true
} else {
isOpen.value = false
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
}
}
function startDrag(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
isDragging.value = true
const rect = terminalRef.value?.getBoundingClientRect()
if (rect) {
// Capture actual position if using default bottom/right
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
const newX = e.clientX - dragOffset.value.x
const newY = e.clientY - dragOffset.value.y
const w = terminalRef.value?.offsetWidth || 580
const h = terminalRef.value?.offsetHeight || 360
// Allow up to 75% occlusion per side (25% must remain visible)
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// Resize functions
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = {
x: e.clientX,
y: e.clientY,
w: size.value.w,
h: size.value.h
}
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
const deltaX = e.clientX - resizeStart.value.x
const deltaY = e.clientY - resizeStart.value.y
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + deltaX, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + deltaY, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => fitAddon?.fit())
}
const terminalStyle = computed(() => {
const base = {
width: `${size.value.w}px`,
height: `${size.value.h}px`
}
if (!hasCustomPosition.value) {
return { ...base, bottom: '16px', right: '16px' }
}
return {
...base,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
function initTerminal() {
if (!terminalContainer.value || terminal) return
terminal = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 12,
fontFamily: "'Consolas', 'Lucida Console', monospace",
theme: {
background: 'rgba(12, 12, 12, 0.95)',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: 'rgba(100, 150, 255, 0.4)',
black: '#0c0c0c',
red: '#c50f1f',
green: '#13a10e',
yellow: '#c19c00',
blue: '#0037da',
magenta: '#881798',
cyan: '#3a96dd',
white: '#cccccc',
brightBlack: '#767676',
brightRed: '#e74856',
brightGreen: '#16c60c',
brightYellow: '#f9f1a5',
brightBlue: '#3b78ff',
brightMagenta: '#b4009e',
brightCyan: '#61d6d6',
brightWhite: '#f2f2f2'
},
allowProposedApi: true
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.open(terminalContainer.value)
nextTick(() => fitAddon?.fit())
resizeObserver = new ResizeObserver(() => {
if (fitAddon && terminal) {
fitAddon.fit()
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows
}))
}
}
})
resizeObserver.observe(terminalContainer.value)
terminal.onData((data) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
})
// Capture Ctrl+E even when terminal has focus
terminal.attachCustomKeyEventHandler((e) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
return false // Prevent terminal from processing
}
return true // Let terminal handle other keys
})
}
async function connect() {
if (connecting.value || connected.value) return
connecting.value = true
try {
socket = new WebSocket(WS_URL)
socket.onopen = () => {
connected.value = true
connecting.value = false
terminal?.focus()
if (terminal) {
socket?.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows
}))
}
}
socket.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
sessionId.value = msg.sessionId
if (!msg.isNew) {
terminal?.write('\x1b[36m[Reconnected]\x1b[0m\r\n')
}
} else if (msg.type === 'replay') {
terminal?.write(msg.data)
} else if (msg.type === 'output') {
terminal?.write(msg.data)
} else if (msg.type === 'exit') {
terminal?.write(msg.data)
sessionId.value = null
} else if (msg.type === 'error') {
terminal?.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
}
}
socket.onclose = () => {
connected.value = false
connecting.value = false
}
socket.onerror = () => {
connecting.value = false
}
} catch (e) {
connecting.value = false
}
}
function close() {
isOpen.value = false
}
function runClaude() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
}
}
watch(isOpen, async (open) => {
if (open) {
await nextTick()
initTerminal()
if (!connected.value && !connecting.value) connect()
nextTick(() => {
fitAddon?.fit()
terminal?.focus()
})
} else {
// Cleanup when closing
resizeObserver?.disconnect()
resizeObserver = null
terminal?.dispose()
terminal = null
fitAddon = null
}
})
onMounted(async () => {
// Global listeners for Ctrl+E
document.addEventListener('mousemove', trackMouse)
document.addEventListener('keydown', handleKeydown)
if (isOpen.value) {
await nextTick()
initTerminal()
connect()
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
socket?.close()
terminal?.dispose()
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleKeydown)
})
// Expose controls for MCP tools
defineExpose({
open: (x?: number, y?: number) => {
if (x !== undefined && y !== undefined) {
position.value = { x, y }
hasCustomPosition.value = true
}
isOpen.value = true
},
close: () => {
isOpen.value = false
},
toggle: () => {
toggleTerminal()
},
move: (x: number, y: number) => {
position.value = { x, y }
hasCustomPosition.value = true
},
resize: (w: number, h: number) => {
size.value = { w: Math.max(400, w), h: Math.max(250, h) }
nextTick(() => fitAddon?.fit())
},
getState: () => ({
isOpen: isOpen.value,
position: position.value,
size: size.value
})
})
</script>
<template>
<Teleport to="body">
<Transition name="win-slide">
<div
v-if="isOpen"
ref="terminalRef"
class="aero-win"
:class="{ dragging: isDragging, resizing: isResizing }"
:style="terminalStyle"
>
<div class="glass">
<!-- 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>
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
</div>
<div class="window-controls">
<button @click="runClaude" title="Claude"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></button>
<button class="x" @click="close" title="Close"><svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
</div>
<!-- Content -->
<div class="content">
<div ref="terminalContainer" class="term"></div>
</div>
<!-- Resize handle -->
<div class="resize-handle" @mousedown="startResize"></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.aero-win {
position: fixed;
min-width: 400px;
min-height: 250px;
z-index: 9999;
}
.glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(200,215,235,0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 5px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow:
0 0 0 1px rgba(80,120,180,0.25),
0 6px 24px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.6);
overflow: hidden;
}
.titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 22px;
padding: 0 2px 0 6px;
background: rgba(255,255,255,0.25);
border-bottom: 1px solid rgba(255,255,255,0.3);
cursor: grab;
user-select: none;
}
.aero-win.dragging .titlebar { cursor: grabbing; }
.left {
display: flex;
align-items: center;
gap: 5px;
color: #222;
font: 500 10px/1 system-ui, sans-serif;
}
.dot {
width: 5px; height: 5px;
border-radius: 50%;
background: #999;
}
.dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
.dot.wait { background: #a80; animation: pulse .8s infinite; }
.link {
margin-left: 2px;
color: #369;
font-size: 9px;
text-decoration: underline;
cursor: pointer;
}
.link:hover { color: #47a; }
.window-controls {
display: flex;
gap: 1px;
}
.window-controls button {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.3);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 2px;
color: #333;
cursor: pointer;
}
.window-controls button:hover {
background: rgba(255,255,255,0.5);
}
.window-controls button.x:hover {
background: linear-gradient(180deg, #e66 0%, #c33 100%);
border-color: #a22;
color: #fff;
}
.content {
flex: 1;
margin: 2px;
border-radius: 2px;
overflow: hidden;
background: rgba(0,0,0,0.92);
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.1) 100%);
border-radius: 0 0 5px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0.2) 100%);
}
.aero-win.resizing {
user-select: none;
}
.aero-win.resizing .term {
pointer-events: none;
}
.term {
width: 100%;
height: 100%;
}
.term :deep(.xterm) {
height: 100%;
padding: 2px;
}
.term :deep(.xterm-viewport) {
overflow-y: auto !important;
}
.term :deep(.xterm-viewport::-webkit-scrollbar) {
width: 8px;
background: rgba(0,0,0,0.2);
}
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(255,255,255,0.15);
border-radius: 4px;
}
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(255,255,255,0.25);
}
.win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
.win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
@media (max-width: 640px) {
.aero-win {
inset: auto 0 0 0 !important;
width: 100% !important;
height: 55% !important;
}
.glass { border-radius: 6px 6px 0 0; }
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useGlobalApproval } from '@/composables/useGlobalApproval'
import PermissionApproval from './transcript-debug/PermissionApproval.vue'
import PlanApproval from './transcript-debug/PlanApproval.vue'
const {
totalPending,
groupedBySession,
modalVisible,
respondPermission,
respondPlan,
ignoreApproval
} = useGlobalApproval()
function truncateId(id: string): string {
if (id.length <= 12) return id
return id.slice(0, 6) + '...' + id.slice(-4)
}
// Auto-hide after 1s when empty
let autoHideTimer: ReturnType<typeof setTimeout> | null = null
watch(totalPending, (val) => {
if (autoHideTimer) {
clearTimeout(autoHideTimer)
autoHideTimer = null
}
if (val === 0 && modalVisible.value) {
autoHideTimer = setTimeout(() => {
modalVisible.value = false
}, 1000)
}
})
</script>
<template>
<Teleport to="body">
<Transition name="approval-modal">
<div v-if="modalVisible" class="approval-backdrop" @click.self="modalVisible = false">
<div class="approval-panel">
<div class="panel-header">
<span class="panel-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Hooks Approval
</span>
<span v-if="totalPending > 0" class="panel-count">{{ totalPending }}</span>
<button class="panel-close" @click="modalVisible = false" title="Minimize">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div class="panel-body">
<template v-if="groupedBySession.length > 0">
<div v-for="group in groupedBySession" :key="group.sessionId" class="session-group">
<div class="session-header">
<span class="session-agent">{{ group.agent }}</span>
<span class="session-sep">/</span>
<span class="session-id" :title="group.sessionId">{{ truncateId(group.sessionId) }}</span>
<span class="session-count">{{ group.permissions.length + group.plans.length }}</span>
</div>
<PermissionApproval
v-for="perm in group.permissions"
:key="perm.requestId"
:request="perm"
@respond="(id, decision, reason) => respondPermission(id, decision, reason)"
@ignore="(id) => ignoreApproval(id)"
/>
<PlanApproval
v-for="plan in group.plans"
:key="plan.requestId"
:request="plan"
@respond="(id, decision, reason) => respondPlan(id, decision, reason)"
/>
</div>
</template>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>No pending approvals</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.approval-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 10012;
display: flex;
align-items: center;
justify-content: center;
}
.approval-panel {
width: 90%;
max-width: 600px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.panel-title svg {
color: #f59e0b;
}
.panel-count {
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
padding: 0.1rem 0.45rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
line-height: 1.3;
}
.panel-close {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.panel-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.session-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-header {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0;
}
.session-agent {
font-size: 12px;
font-weight: 600;
color: var(--accent, #6366f1);
}
.session-sep {
font-size: 11px;
color: var(--text-muted);
opacity: 0.4;
}
.session-id {
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.session-count {
margin-left: auto;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-hover);
padding: 0.1rem 0.4rem;
border-radius: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-muted);
font-size: 13px;
}
/* Modal transition */
.approval-modal-enter-active,
.approval-modal-leave-active {
transition: opacity 0.2s ease;
}
.approval-modal-enter-active .approval-panel,
.approval-modal-leave-active .approval-panel {
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}
.approval-modal-enter-from,
.approval-modal-leave-to {
opacity: 0;
}
.approval-modal-enter-from .approval-panel {
transform: scale(0.95);
opacity: 0;
}
.approval-modal-leave-to .approval-panel {
transform: scale(0.95);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,282 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useServerConfig } from '@/stores/server-config'
const serverConfig = useServerConfig()
const urlInput = ref('')
const testing = ref(false)
const testResult = ref<'idle' | 'success' | 'error'>('idle')
async function handleTest() {
if (!urlInput.value.trim()) return
testing.value = true
testResult.value = 'idle'
const ok = await serverConfig.testConnection(urlInput.value.trim())
testResult.value = ok ? 'success' : 'error'
testing.value = false
}
async function handleConnect() {
if (!urlInput.value.trim()) return
const ok = await serverConfig.setServer(urlInput.value.trim())
if (ok) {
testResult.value = 'success'
}
}
function selectRecent(url: string) {
urlInput.value = url
testResult.value = 'idle'
}
onMounted(() => {
if (serverConfig.serverUrl) {
urlInput.value = serverConfig.serverUrl
}
})
</script>
<template>
<div class="server-config-overlay">
<div class="server-config-dialog">
<div class="dialog-header">
<h2>Connect to Server</h2>
<p class="dialog-subtitle">Enter the URL of your Agent UI backend</p>
</div>
<div class="dialog-body">
<div class="input-group">
<label for="server-url">Server URL</label>
<div class="input-row">
<input
id="server-url"
v-model="urlInput"
type="url"
placeholder="https://your-server.com or http://192.168.1.100:4101"
@keydown.enter="handleConnect"
:disabled="serverConfig.loading"
/>
<button
class="btn btn-test"
@click="handleTest"
:disabled="!urlInput.trim() || testing"
>
{{ testing ? 'Testing...' : 'Test' }}
</button>
</div>
<div v-if="testResult === 'success'" class="status-msg success">
Connected successfully
</div>
<div v-if="testResult === 'error'" class="status-msg error">
Could not connect to server
</div>
<div v-if="serverConfig.error" class="status-msg error">
{{ serverConfig.error }}
</div>
</div>
<div v-if="serverConfig.recentUrls.length > 0" class="recent-urls">
<label>Recent</label>
<div class="recent-list">
<button
v-for="url in serverConfig.recentUrls"
:key="url"
class="recent-item"
@click="selectRecent(url)"
>
{{ url }}
</button>
</div>
</div>
</div>
<div class="dialog-footer">
<button
class="btn btn-primary"
@click="handleConnect"
:disabled="!urlInput.trim() || serverConfig.loading"
>
{{ serverConfig.loading ? 'Connecting...' : 'Connect' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.server-config-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 99999;
}
.server-config-dialog {
background: var(--bg-primary, #0f0f14);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 12px;
width: 90%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.dialog-header {
padding: 1.5rem 1.5rem 0;
}
.dialog-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #e4e4e7);
}
.dialog-subtitle {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--text-secondary, #a1a1aa);
}
.dialog-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-group label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #a1a1aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-row input {
flex: 1;
padding: 0.6rem 0.8rem;
background: var(--bg-secondary, #1a1a24);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 8px;
color: var(--text-primary, #e4e4e7);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus {
border-color: var(--accent, #6366f1);
}
.input-row input::placeholder {
color: var(--text-muted, #52525b);
}
.btn {
padding: 0.6rem 1rem;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 8px;
background: var(--bg-secondary, #1a1a24);
color: var(--text-primary, #e4e4e7);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover, #252530);
border-color: var(--accent, #6366f1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
color: white;
font-weight: 500;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
border-color: #4f46e5;
}
.status-msg {
font-size: 0.8rem;
padding: 0.4rem 0;
}
.status-msg.success {
color: #10b981;
}
.status-msg.error {
color: #ef4444;
}
.recent-urls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recent-urls label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #a1a1aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.recent-item {
padding: 0.4rem 0.6rem;
background: transparent;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.06));
border-radius: 6px;
color: var(--text-secondary, #a1a1aa);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
.recent-item:hover {
background: var(--bg-hover, #252530);
color: var(--text-primary, #e4e4e7);
border-color: var(--accent, #6366f1);
}
.dialog-footer {
padding: 0 1.5rem 1.5rem;
}
</style>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
defineProps<{
waitingForToken?: boolean
showStart?: boolean
showRestart?: boolean
}>()
defineEmits<{
requestToken: []
runClaude: []
runClaudeContinue: []
runClaudeResume: []
clearBuffer: []
refresh: []
restart: []
sendKey: [key: string]
scroll: [direction: 'up' | 'down' | 'end']
}>()
</script>
<template>
<div class="tnb-bar">
<!-- Agent lifecycle -->
<button v-if="showStart" class="tnb-btn start" title="Start Agent" @click="$emit('restart')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span class="tnb-label">Start</span>
</button>
<button v-if="showRestart" class="tnb-btn restart" title="Restart Agent" @click="$emit('restart')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
<span class="tnb-label">Restart</span>
</button>
<span v-if="showStart || showRestart" class="tnb-sep"></span>
<!-- Action buttons -->
<button class="tnb-btn mcp" :class="{ waiting: waitingForToken }" title="Connect MCP" @click="$emit('requestToken')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span class="tnb-label">MCP</span>
</button>
<button class="tnb-btn claude" title="Run Claude" @click="$emit('runClaude')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
<span class="tnb-label">Claude</span>
</button>
<button class="tnb-btn continue" title="Continue" @click="$emit('runClaudeContinue')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
<span class="tnb-label">Cont</span>
</button>
<button class="tnb-btn resume-btn" title="Resume" @click="$emit('runClaudeResume')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><path d="M21 3v6h-6"/></svg>
<span class="tnb-label">Resume</span>
</button>
<button class="tnb-btn clear" title="Clear Buffer" @click="$emit('clearBuffer')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14"/></svg>
<span class="tnb-label">Clear</span>
</button>
<button class="tnb-btn refresh-btn" title="Refresh Screen" @click="$emit('refresh')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
<span class="tnb-label">Refresh</span>
</button>
<span class="tnb-sep"></span>
<!-- Special keys -->
<button class="tnb-btn key esc" title="Escape" @click="$emit('sendKey', 'esc')">Esc</button>
<button class="tnb-btn key tab" title="Tab" @click="$emit('sendKey', 'tab')">Tab</button>
<button class="tnb-btn key ctrl-c" title="Ctrl+C" @click="$emit('sendKey', 'ctrl-c')">^C</button>
<button class="tnb-btn key alt-m" title="Alt+M" @click="$emit('sendKey', 'alt-m')">Alt+M</button>
<span class="tnb-sep"></span>
<!-- Scroll buttons -->
<div class="tnb-group">
<button class="tnb-btn scroll-btn" title="Scroll up" @click="$emit('scroll', 'up')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button class="tnb-btn scroll-btn end" title="Scroll to bottom" @click="$emit('scroll', 'end')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M7 7l5 5 5-5"/><path d="M7 13l5 5 5-5"/></svg>
</button>
<button class="tnb-btn scroll-btn" title="Scroll down" @click="$emit('scroll', 'down')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
<!-- Arrow keys -->
<div class="tnb-group arrows">
<button class="tnb-btn arrow" title="Up" @click="$emit('sendKey', 'up')"></button>
<div class="tnb-arrow-row">
<button class="tnb-btn arrow" title="Left" @click="$emit('sendKey', 'left')"></button>
<button class="tnb-btn arrow" title="Down" @click="$emit('sendKey', 'down')"></button>
<button class="tnb-btn arrow" title="Right" @click="$emit('sendKey', 'right')"></button>
</div>
</div>
</div>
</template>
<style scoped>
.tnb-bar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px 6px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
flex-wrap: wrap;
}
.tnb-bar::-webkit-scrollbar { display: none; }
.tnb-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 3px;
flex-shrink: 0;
}
.tnb-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 10px;
font-family: system-ui, sans-serif;
white-space: nowrap;
transition: all 0.12s;
flex-shrink: 0;
}
.tnb-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
.tnb-btn:active {
transform: scale(0.96);
}
.tnb-label {
font-weight: 500;
}
/* Agent lifecycle */
.tnb-btn.start { color: #10b981; border-color: rgba(16, 185, 129, 0.2); }
.tnb-btn.start:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #34d399; }
.tnb-btn.restart { color: rgba(245, 158, 11, 0.8); border-color: rgba(245, 158, 11, 0.2); }
.tnb-btn.restart:hover { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.3); color: #f59e0b; }
/* Action button variants */
.tnb-btn.mcp:hover { background: rgba(236, 72, 153, 0.2); border-color: rgba(236, 72, 153, 0.3); color: #ec4899; }
.tnb-btn.mcp.waiting { background: rgba(16, 185, 129, 0.2); border-color: #10b981; color: #10b981; animation: tnb-pulse 0.8s infinite; }
.tnb-btn.claude:hover { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
.tnb-btn.continue:hover { background: rgba(6, 182, 212, 0.2); border-color: rgba(6, 182, 212, 0.3); color: #06b6d4; }
.tnb-btn.resume-btn:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981; }
.tnb-btn.clear:hover { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.3); color: #f59e0b; }
.tnb-btn.refresh-btn:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
/* Key buttons */
.tnb-btn.key {
padding: 3px 6px;
font-weight: 600;
font-size: 9px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
.tnb-btn.ctrl-c:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.3); color: #ef4444; }
.tnb-btn.alt-m:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
/* Scroll buttons */
.tnb-btn.scroll-btn {
padding: 2px 5px;
}
.tnb-btn.scroll-btn:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981; }
.tnb-btn.scroll-btn.end:hover { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
/* Arrow buttons */
.tnb-btn.arrow {
padding: 1px 4px;
font-size: 8px;
min-width: 20px;
justify-content: center;
}
/* Groups */
.tnb-group {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
.tnb-group.arrows {
flex-direction: column;
gap: 1px;
}
.tnb-arrow-row {
display: flex;
gap: 1px;
}
@keyframes tnb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
</style>

View File

@@ -4,25 +4,16 @@ import { RouterLink, useRoute } from 'vue-router'
import { useCanvasStore } from '../stores/canvas'
import { useProjectCanvasStore } from '../stores/projectCanvas'
defineProps<{
collapsed?: boolean
}>()
const route = useRoute()
const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore()
function clearCanvas() {
const container = document.getElementById('canvas-content')
if (container) {
container.innerHTML = `
<div class="canvas-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
<p>Canvas listo</p>
<span>Claude Code puede renderizar contenido aquí</span>
</div>
`
}
window.dispatchEvent(new CustomEvent('clear-canvas'))
}
function toggleHistory() {
@@ -39,7 +30,7 @@ onMounted(() => {
</script>
<template>
<aside class="toolbar">
<aside class="toolbar" :class="{ collapsed }">
<!-- Navegacion principal -->
<div class="toolbar-section nav-section">
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Home">
@@ -79,12 +70,6 @@ onMounted(() => {
<!-- Gestion -->
<div class="toolbar-section">
<RouterLink to="/projects" class="toolbar-btn" :class="{ active: route.path === '/projects' }" title="Proyectos">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</RouterLink>
<RouterLink to="/components" class="toolbar-btn" :class="{ active: route.path === '/components' }" title="Componentes">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
@@ -119,18 +104,35 @@ onMounted(() => {
</svg>
</RouterLink>
<RouterLink to="/terminal" class="toolbar-btn" :class="{ active: route.path === '/terminal' }" title="Terminal">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</RouterLink>
<RouterLink to="/tools" class="toolbar-btn" :class="{ active: route.path === '/tools' }" title="Tools">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</RouterLink>
<RouterLink to="/git" class="toolbar-btn" :class="{ active: route.path === '/git' }" title="Git">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="6" y1="3" x2="6" y2="15"/>
<circle cx="18" cy="6" r="3"/>
<circle cx="6" cy="18" r="3"/>
<path d="M18 9a9 9 0 0 1-9 9"/>
</svg>
</RouterLink>
<RouterLink to="/agents" class="toolbar-btn" :class="{ active: route.path === '/agents' }" title="Agents">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L9.5 7.5 4 8.5l4 4-1 5.5L12 15l5 3-1-5.5 4-4-5.5-1z"/>
</svg>
</RouterLink>
<RouterLink to="/transcript-debug" class="toolbar-btn" :class="{ active: route.path === '/transcript-debug' }" title="Transcript Debug">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</RouterLink>
</div>
<div class="toolbar-divider"></div>
@@ -164,6 +166,19 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease;
flex-shrink: 0;
}
.toolbar.collapsed {
width: 0;
padding: 0;
border-right-color: transparent;
opacity: 0;
pointer-events: none;
}
.toolbar-section {

View File

@@ -21,8 +21,7 @@ const categoryTools: Record<ToolCategory, string[]> = {
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components'],
terminal: ['terminal_open', 'terminal_close', 'terminal_toggle', 'terminal_move', 'terminal_resize']
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components']
}
const categories = computed(() => {

View File

@@ -0,0 +1,618 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useTorchStore } from '../stores/torch'
import { useCanvasStore } from '../stores/canvas'
import { requestTorch, releaseTorch, transferTorch, updateName, setAutoRequest } from '../services/torch'
const torchStore = useTorchStore()
const canvasStore = useCanvasStore()
const isOpen = ref(false)
const isEditingName = ref(false)
const nameInput = ref('')
const nameInputRef = ref<HTMLInputElement | null>(null)
// Combined state
const hasTorch = computed(() => torchStore.hasTorch)
const isConnected = computed(() => canvasStore.isConnected)
const isReconnecting = computed(() => canvasStore.isReconnecting)
const displayName = computed(() => torchStore.clientName || 'Anonymous')
const statusClass = computed(() => {
if (!torchStore.isConnected) return 'disconnected'
if (!hasTorch.value) return 'no-torch'
if (isReconnecting.value) return 'reconnecting'
if (isConnected.value) return 'connected'
return 'has-torch'
})
const statusText = computed(() => {
if (!torchStore.isConnected) return 'Offline'
if (!hasTorch.value) return 'No torch'
if (isReconnecting.value) return 'Reconnecting'
if (isConnected.value) return 'Connected'
return 'Connecting...'
})
const statusBadgeClass = computed(() => {
if (!torchStore.isConnected) return 'error'
if (!hasTorch.value) return 'error'
if (isReconnecting.value || !isConnected.value) return 'warning'
return 'success'
})
function toggleDropdown() {
isOpen.value = !isOpen.value
}
function closeDropdown(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.torch-dropdown-container')) {
isOpen.value = false
isEditingName.value = false
}
}
function startEditingName() {
nameInput.value = torchStore.clientName
isEditingName.value = true
nextTick(() => {
nameInputRef.value?.focus()
nameInputRef.value?.select()
})
}
function saveName() {
const trimmed = nameInput.value.trim().substring(0, 20)
updateName(trimmed)
isEditingName.value = false
}
function cancelEditName() {
isEditingName.value = false
}
async function handleAction() {
if (torchStore.hasTorch) {
await releaseTorch()
} else {
await requestTorch()
}
}
async function handleTransfer(targetId: string) {
await transferTorch(targetId)
}
function toggleAutoRequest() {
setAutoRequest(!torchStore.autoRequest)
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<template>
<div class="torch-dropdown-container">
<!-- Split Trigger: main area = request/release, chevron = dropdown -->
<div class="trigger-split" :class="[statusClass, { requesting: torchStore.isRequesting }]">
<button class="trigger-main" @click.stop="handleAction" :title="hasTorch ? 'Release torch' : 'Request torch'">
<span class="status-dot" :class="statusBadgeClass"></span>
<span class="trigger-name">{{ displayName }}</span>
<span v-if="torchStore.isRequesting" class="requesting-indicator"></span>
</button>
<button class="trigger-chevron" @click.stop="toggleDropdown" title="Settings">
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
<!-- Dropdown -->
<div v-if="isOpen" class="dropdown-menu" @click.stop>
<!-- Header -->
<div class="dropdown-header">
<span class="header-title">Torch</span>
<span class="status-badge" :class="statusBadgeClass">{{ statusText }}</span>
</div>
<!-- Name Section -->
<div class="name-section">
<label class="section-label">Name</label>
<div v-if="isEditingName" class="name-edit">
<input
ref="nameInputRef"
v-model="nameInput"
type="text"
class="name-input"
maxlength="20"
placeholder="Anonymous"
@keyup.enter="saveName"
@keyup.escape="cancelEditName"
@blur="saveName"
/>
</div>
<button v-else class="name-display" @click="startEditingName">
<span>{{ displayName }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
<!-- Auto-request toggle -->
<div class="auto-request-section">
<label class="toggle-row" @click="toggleAutoRequest">
<span class="toggle-label">Auto-request</span>
<span class="toggle-switch" :class="{ active: torchStore.autoRequest }">
<span class="toggle-knob"></span>
</span>
</label>
</div>
<!-- Clients list -->
<div class="clients-section">
<label class="section-label">Clients ({{ torchStore.clients.length }})</label>
<div class="clients-list">
<div
v-for="client in torchStore.clients"
:key="client.id"
class="client-row"
>
<span class="client-dot" :class="{ holder: client.hasTorch }"></span>
<span class="client-name">{{ client.name || 'Anonymous' }}</span>
<span v-if="client.id === torchStore.clientId" class="you-badge">you</span>
<span v-if="client.hasTorch" class="torch-badge">torch</span>
<button
v-if="!client.hasTorch && client.id !== torchStore.clientId && torchStore.hasTorch"
class="transfer-btn"
@click="handleTransfer(client.id)"
title="Transfer torch"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
</svg>
</button>
</div>
<div v-if="torchStore.clients.length === 0" class="no-clients">
No clients connected
</div>
</div>
</div>
<!-- Action button -->
<div class="action-section">
<button
class="action-btn"
:class="hasTorch ? 'release' : 'request'"
@click="handleAction"
:disabled="torchStore.isRequesting"
>
{{ torchStore.isRequesting ? 'Requesting...' : hasTorch ? 'Release Torch' : 'Request Torch' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.torch-dropdown-container {
position: relative;
overflow: visible;
}
.trigger-split {
display: flex;
align-items: center;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
transition: all 0.15s ease;
position: relative;
}
.trigger-main {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem 0.375rem 0.75rem;
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
transition: background 0.1s;
border-radius: 7px 0 0 7px;
}
.trigger-main:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
}
.trigger-chevron {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
background: none;
border: none;
border-left: 1px solid var(--border-color);
color: inherit;
cursor: pointer;
transition: background 0.1s;
border-radius: 0 7px 7px 0;
}
.trigger-chevron:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
}
.trigger-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.trigger-name {
display: none;
}
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.success {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
}
.status-dot.warning {
background: #f59e0b;
animation: dot-pulse 1.5s infinite;
}
.status-dot.error {
background: #ef4444;
}
.chevron {
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron.open {
transform: rotate(180deg);
}
.requesting-indicator {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #6366f1;
border-radius: 50%;
animation: request-pulse 1s ease-in-out infinite;
}
/* Dropdown menu */
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.header-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.status-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: 9999px;
}
.status-badge.success {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.status-badge.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* Name section */
.name-section {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.section-label {
display: block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.name-display {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.name-display:hover {
border-color: var(--border-color);
background: var(--bg-hover);
}
.name-display svg {
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s;
}
.name-display:hover svg {
opacity: 1;
}
.name-input {
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
}
/* Auto-request section */
.auto-request-section {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.25rem 0;
}
.toggle-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
transition: all 0.2s ease;
}
.toggle-switch.active {
background: #10b981;
border-color: #10b981;
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-switch.active .toggle-knob {
transform: translateX(16px);
}
/* Clients section */
.clients-section {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.clients-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 160px;
overflow-y: auto;
}
.client-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 6px;
transition: background 0.1s;
}
.client-row:hover {
background: var(--bg-hover);
}
.client-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.client-dot.holder {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
}
.client-name {
flex: 1;
font-size: 0.8rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.you-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
flex-shrink: 0;
}
.torch-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
flex-shrink: 0;
}
.transfer-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.transfer-btn:hover {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.no-clients {
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
padding: 0.5rem 0;
}
/* Action section */
.action-section {
padding: 0.75rem 1rem;
}
.action-btn {
width: 100%;
padding: 0.5rem;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.action-btn.request {
background: var(--accent, #6366f1);
color: white;
}
.action-btn.request:hover:not(:disabled) {
filter: brightness(1.1);
}
.action-btn.release {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.action-btn.release:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Animations */
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes request-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.7; }
}
</style>

View File

@@ -0,0 +1,407 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
id: string
title?: string
x?: number
y?: number
width?: number
height?: number
minWidth?: number
minHeight?: number
}>(), {
title: 'Window',
x: 50,
y: 50,
width: 400,
height: 300,
minWidth: 200,
minHeight: 100
})
const emit = defineEmits<{
close: []
move: [pos: { x: number; y: number }]
resize: [size: { width: number; height: number }]
focus: []
}>()
// Estado interno de la ventana
const currentX = ref(props.x)
const currentY = ref(props.y)
const currentWidth = ref(props.width)
const currentHeight = ref(props.height)
const zIndex = ref(100)
// Estado de drag
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragOffsetX = ref(0)
const dragOffsetY = ref(0)
// Estado de resize
const isResizing = ref(false)
const resizeDirection = ref('')
const resizeStartX = ref(0)
const resizeStartY = ref(0)
const resizeStartWidth = ref(0)
const resizeStartHeight = ref(0)
const windowStyle = computed(() => ({
left: `${currentX.value}px`,
top: `${currentY.value}px`,
width: `${currentWidth.value}px`,
height: `${currentHeight.value}px`,
zIndex: zIndex.value
}))
// Drag handlers
function startDrag(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
isDragging.value = true
dragStartX.value = e.clientX
dragStartY.value = e.clientY
dragOffsetX.value = currentX.value
dragOffsetY.value = currentY.value
bringToFront()
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
const deltaX = e.clientX - dragStartX.value
const deltaY = e.clientY - dragStartY.value
currentX.value = Math.max(0, dragOffsetX.value + deltaX)
currentY.value = Math.max(0, dragOffsetY.value + deltaY)
}
function stopDrag() {
if (isDragging.value) {
isDragging.value = false
emit('move', { x: currentX.value, y: currentY.value })
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// Resize handlers
function startResize(direction: string, e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeDirection.value = direction
resizeStartX.value = e.clientX
resizeStartY.value = e.clientY
resizeStartWidth.value = currentWidth.value
resizeStartHeight.value = currentHeight.value
bringToFront()
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
const deltaX = e.clientX - resizeStartX.value
const deltaY = e.clientY - resizeStartY.value
if (resizeDirection.value.includes('e')) {
currentWidth.value = Math.max(props.minWidth, resizeStartWidth.value + deltaX)
}
if (resizeDirection.value.includes('s')) {
currentHeight.value = Math.max(props.minHeight, resizeStartHeight.value + deltaY)
}
if (resizeDirection.value.includes('w')) {
const newWidth = Math.max(props.minWidth, resizeStartWidth.value - deltaX)
if (newWidth !== currentWidth.value) {
currentX.value = resizeStartX.value + (resizeStartWidth.value - newWidth) + (e.clientX - resizeStartX.value) - deltaX
currentWidth.value = newWidth
}
}
if (resizeDirection.value.includes('n')) {
const newHeight = Math.max(props.minHeight, resizeStartHeight.value - deltaY)
if (newHeight !== currentHeight.value) {
currentY.value = resizeStartY.value + (resizeStartHeight.value - newHeight) + (e.clientY - resizeStartY.value) - deltaY
currentHeight.value = newHeight
}
}
}
function stopResize() {
if (isResizing.value) {
isResizing.value = false
emit('resize', { width: currentWidth.value, height: currentHeight.value })
}
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
}
// Z-index management
let maxZIndex = 100
function bringToFront() {
maxZIndex++
zIndex.value = maxZIndex
emit('focus')
}
function handleWindowClick() {
bringToFront()
}
// Cleanup
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<template>
<div
class="window-container"
:style="windowStyle"
:data-window-id="id"
@mousedown="handleWindowClick"
>
<!-- Header / Title bar -->
<div class="window-header" @mousedown="startDrag">
<span class="window-title">{{ title }}</span>
<div class="window-controls">
<button @click.stop="emit('close')" class="btn-close" title="Cerrar">
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
<!-- Content area -->
<div class="window-content">
<slot></slot>
</div>
<!-- Resize handles -->
<div class="resize-handle resize-n" @mousedown="startResize('n', $event)"></div>
<div class="resize-handle resize-e" @mousedown="startResize('e', $event)"></div>
<div class="resize-handle resize-s" @mousedown="startResize('s', $event)"></div>
<div class="resize-handle resize-w" @mousedown="startResize('w', $event)"></div>
<div class="resize-handle resize-ne" @mousedown="startResize('ne', $event)"></div>
<div class="resize-handle resize-se" @mousedown="startResize('se', $event)"></div>
<div class="resize-handle resize-sw" @mousedown="startResize('sw', $event)"></div>
<div class="resize-handle resize-nw" @mousedown="startResize('nw', $event)"></div>
</div>
</template>
<style>
.window-container {
position: absolute;
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 14px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.2),
inset 0 0.5px 0 rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 200px;
min-height: 100px;
}
.window-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 8px;
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
cursor: move;
user-select: none;
flex-shrink: 0;
height: 20px;
}
.window-title {
font-size: 11px;
font-weight: 400;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.window-controls {
display: flex;
margin-left: 4px;
}
.btn-close {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: rgba(255, 59, 48, 0.7);
color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
}
.btn-close svg {
width: 7px;
height: 7px;
opacity: 0;
transition: opacity 0.15s ease;
}
.btn-close:hover {
background: rgba(255, 59, 48, 1);
color: white;
}
.btn-close:hover svg {
opacity: 1;
}
.window-content {
flex: 1;
overflow: auto;
padding: 10px;
background: transparent;
position: relative;
z-index: 1;
}
.window-content::-webkit-scrollbar {
width: 3px;
height: 3px;
}
.window-content::-webkit-scrollbar-track {
background: transparent;
}
.window-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.window-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.window-content::-webkit-scrollbar-corner {
background: transparent;
}
/* Resize handles */
.resize-handle {
position: absolute;
background: transparent;
z-index: 10;
}
.resize-n {
top: 0;
left: 8px;
right: 8px;
height: 4px;
cursor: n-resize;
}
.resize-e {
right: 0;
top: 8px;
bottom: 8px;
width: 4px;
cursor: e-resize;
}
.resize-s {
bottom: 0;
left: 8px;
right: 8px;
height: 4px;
cursor: s-resize;
}
.resize-w {
left: 0;
top: 8px;
bottom: 8px;
width: 4px;
cursor: w-resize;
}
.resize-ne {
top: 0;
right: 0;
width: 12px;
height: 12px;
cursor: ne-resize;
}
.resize-se {
bottom: 0;
right: 0;
width: 12px;
height: 12px;
cursor: se-resize;
}
.resize-sw {
bottom: 0;
left: 0;
width: 12px;
height: 12px;
cursor: sw-resize;
}
.resize-nw {
top: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nw-resize;
}
/* Indicador visual de resize en esquinas */
.resize-se::after {
content: '';
position: absolute;
bottom: 3px;
right: 3px;
width: 6px;
height: 6px;
border-right: 2px solid #444;
border-bottom: 2px solid #444;
opacity: 0.5;
}
.window-container:hover .resize-se::after {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// Always show on non-mobile — we have decorations:false so we need these
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent)
const show = !isMobile
const isMaximized = ref(false)
let unlisten: (() => void) | null = null
async function getWin() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
return getCurrentWindow()
}
async function initWindowState() {
try {
const win = await getWin()
isMaximized.value = await win.isMaximized()
unlisten = await win.onResized(async () => {
isMaximized.value = await win.isMaximized()
})
} catch {}
}
async function minimizeWindow() {
try { (await getWin()).minimize() } catch { }
}
async function toggleMaximize() {
try { (await getWin()).toggleMaximize() } catch { }
}
async function closeWindow() {
try {
(await getWin()).close()
} catch {
window.close()
}
}
onMounted(() => { if (show) initWindowState() })
onUnmounted(() => { unlisten?.() })
</script>
<template>
<div v-if="show" class="window-controls">
<button class="wc-btn wc-minimize" @click="minimizeWindow" title="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor" />
</svg>
</button>
<button class="wc-btn wc-maximize" @click="toggleMaximize" :title="isMaximized ? 'Restore' : 'Maximize'">
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10">
<rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1" />
</svg>
<svg v-else width="10" height="10" viewBox="0 0 10 10">
<rect x="2.5" y="0.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="2.5" width="7" height="7" fill="var(--bg-primary, #0f0f14)" stroke="currentColor" stroke-width="1" />
</svg>
</button>
<button class="wc-btn wc-close" @click="closeWindow" title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
</div>
</template>
<style scoped>
.window-controls {
display: flex;
align-items: stretch;
height: 100%;
margin-left: 0.5rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.wc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 100%;
min-height: 32px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary, #a1a1aa);
cursor: pointer;
transition: background 0.1s ease, color 0.1s ease;
outline: none;
}
.wc-btn:hover {
background: var(--bg-hover, #1e1e28);
color: var(--text-primary, #e4e4e7);
}
.wc-btn:active {
background: var(--border-color, #2a2a3a);
}
.wc-close:hover {
background: #e81123;
color: #ffffff;
}
.wc-close:active {
background: #c50f1f;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,505 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAgentsStore, HOOK_EVENT_TYPES } from '../../stores/agents'
import type { HookEntry, HookCommand } from '../../stores/agents'
const store = useAgentsStore()
const editingHook = ref<{ eventType: string; index: number } | null>(null)
const editMatcher = ref('')
const editCommand = ref('')
const editTimeout = ref(5000)
function hookCount(eventType: string): number {
return store.hooksConfig[eventType]?.length || 0
}
function totalHookCount(): number {
return Object.values(store.hooksConfig).reduce((sum, arr) => sum + (arr?.length || 0), 0)
}
function startEdit(eventType: string, index: number) {
const entry = store.hooksConfig[eventType]?.[index]
if (!entry) return
editingHook.value = { eventType, index }
editMatcher.value = entry.matcher || ''
editCommand.value = entry.hooks?.[0]?.command || ''
editTimeout.value = entry.hooks?.[0]?.timeout || 5000
}
function saveEdit() {
if (!editingHook.value) return
const { eventType, index } = editingHook.value
const entry: HookEntry = {
matcher: editMatcher.value.trim() || null,
hooks: [{
type: 'command',
command: editCommand.value.trim(),
timeout: editTimeout.value
}]
}
store.updateHook(eventType, index, entry)
editingHook.value = null
}
function cancelEdit() {
editingHook.value = null
}
function truncateCommand(cmd: string, max = 60): string {
return cmd.length > max ? cmd.slice(0, max) + '...' : cmd
}
const eventTypeLabels: Record<string, string> = {
'UserPromptSubmit': 'User Prompt Submit',
'PreToolUse': 'Pre Tool Use',
'PostToolUse': 'Post Tool Use',
'SessionStart': 'Session Start',
'Stop': 'Stop',
'Notification': 'Notification',
'PermissionRequest': 'Permission Request'
}
const eventTypeColors: Record<string, string> = {
'UserPromptSubmit': '#3b82f6',
'PreToolUse': '#f59e0b',
'PostToolUse': '#22c55e',
'SessionStart': '#6366f1',
'Stop': '#ef4444',
'Notification': '#8b5cf6',
'PermissionRequest': '#06b6d4'
}
</script>
<template>
<div class="hooks-manager">
<!-- Header -->
<div class="hooks-header">
<div class="hooks-header-left">
<h3>Hooks</h3>
<span class="hooks-count">{{ totalHookCount() }} hooks</span>
<span v-if="store.configFile" class="hooks-path">{{ store.configFile }}</span>
</div>
<div class="hooks-header-right">
<button
class="btn btn-primary"
:disabled="store.saving"
@click="store.saveHooks()"
>{{ store.saving ? 'Saving...' : 'Save Hooks' }}</button>
</div>
</div>
<!-- Loading -->
<div v-if="store.hooksLoading" class="hooks-loading">Loading hooks...</div>
<!-- Error -->
<div v-if="store.error" class="hooks-error">{{ store.error }}</div>
<!-- Hook sections by event type -->
<div v-if="!store.hooksLoading" class="hooks-scroll">
<div v-for="eventType in HOOK_EVENT_TYPES" :key="eventType" class="hook-section">
<button
class="hook-section-header"
@click="store.toggleHookType(eventType)"
>
<span class="hook-dot" :style="{ background: eventTypeColors[eventType] }"></span>
<span class="hook-type-label">{{ eventTypeLabels[eventType] || eventType }}</span>
<span
v-if="hookCount(eventType)"
class="hook-type-count"
:style="{ background: eventTypeColors[eventType] + '18', color: eventTypeColors[eventType] }"
>{{ hookCount(eventType) }}</span>
<svg
class="hook-chevron"
:class="{ expanded: store.expandedHookTypes.has(eventType) }"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div v-if="store.expandedHookTypes.has(eventType)" class="hook-section-body">
<!-- Hook entries -->
<div
v-for="(entry, idx) in (store.hooksConfig[eventType] || [])"
:key="idx"
class="hook-entry"
>
<!-- View mode -->
<template v-if="!editingHook || editingHook.eventType !== eventType || editingHook.index !== idx">
<div class="hook-entry-info">
<div v-if="entry.matcher" class="hook-matcher">
<span class="entry-label">Matcher</span>
<code>{{ entry.matcher }}</code>
</div>
<div v-for="(hook, hIdx) in entry.hooks" :key="hIdx" class="hook-command-row">
<span class="entry-label">Command</span>
<code :title="hook.command">{{ truncateCommand(hook.command) }}</code>
<span v-if="hook.timeout" class="hook-timeout">{{ hook.timeout }}ms</span>
</div>
</div>
<div class="hook-entry-actions">
<button class="btn-link" @click="startEdit(eventType, idx)">Edit</button>
<button class="btn-link danger" @click="store.removeHook(eventType, idx)">Remove</button>
</div>
</template>
<!-- Edit mode -->
<template v-else>
<div class="hook-edit-form">
<div class="form-row">
<label>Matcher</label>
<input type="text" v-model="editMatcher" placeholder=".* (regex or empty)" />
</div>
<div class="form-row">
<label>Command</label>
<textarea v-model="editCommand" rows="2" placeholder="bash script.sh"></textarea>
</div>
<div class="form-row">
<label>Timeout (ms)</label>
<input type="number" v-model.number="editTimeout" />
</div>
<div class="form-row-actions">
<button class="btn btn-secondary btn-xs" @click="cancelEdit">Cancel</button>
<button class="btn btn-primary btn-xs" @click="saveEdit">Save</button>
</div>
</div>
</template>
</div>
<!-- Empty state for this type -->
<div v-if="!hookCount(eventType)" class="hook-empty">No hooks configured</div>
<!-- Add button -->
<button class="hook-add-btn" @click="store.addHook(eventType)">
+ Add hook
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.hooks-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.hooks-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
gap: 1rem;
flex-shrink: 0;
}
.hooks-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.hooks-header-left h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.hooks-count {
font-size: 0.6875rem;
color: var(--text-muted);
padding: 0.125rem 0.5rem;
background: var(--bg-hover);
border-radius: 999px;
}
.hooks-path {
font-size: 0.6875rem;
color: var(--text-muted);
font-family: 'Consolas', monospace;
}
.hooks-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hooks-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.8125rem;
}
.hooks-error {
padding: 0.625rem 1.25rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.8125rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
}
.hooks-scroll {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1.25rem;
}
/* Hook sections */
.hook-section {
margin-bottom: 4px;
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.hook-section-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
background: var(--bg-secondary);
border: none;
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.hook-section-header:hover {
background: var(--bg-hover);
}
.hook-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.hook-type-label {
flex: 1;
text-align: left;
}
.hook-type-count {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.4375rem;
border-radius: 999px;
}
.hook-chevron {
color: var(--text-muted);
transition: transform 0.2s;
flex-shrink: 0;
transform: rotate(-90deg);
}
.hook-chevron.expanded {
transform: rotate(0deg);
}
.hook-section-body {
border-top: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
}
/* Hook entries */
.hook-entry {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 0.5rem 0;
gap: 0.5rem;
}
.hook-entry + .hook-entry {
border-top: 1px solid var(--border-color);
}
.hook-entry-info {
flex: 1;
min-width: 0;
}
.hook-matcher,
.hook-command-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.125rem 0;
}
.entry-label {
font-size: 0.5625rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
min-width: 55px;
flex-shrink: 0;
}
.hook-entry code {
font-size: 0.6875rem;
color: var(--text-secondary);
font-family: 'Consolas', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hook-timeout {
font-size: 0.625rem;
color: var(--text-muted);
flex-shrink: 0;
}
.hook-entry-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
padding-top: 0.125rem;
}
.btn-link {
background: none;
border: none;
color: #6366f1;
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.btn-link:hover {
text-decoration: underline;
}
.btn-link.danger {
color: #ef4444;
}
/* Edit form */
.hook-edit-form {
width: 100%;
padding: 0.5rem 0;
}
.form-row {
margin-bottom: 0.5rem;
}
.form-row label {
display: block;
font-size: 0.625rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.1875rem;
text-transform: uppercase;
}
.form-row input,
.form-row textarea {
width: 100%;
padding: 0.3125rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
font-family: 'Consolas', monospace;
box-sizing: border-box;
}
.form-row input:focus,
.form-row textarea:focus {
border-color: #6366f1;
}
.form-row-actions {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
}
.hook-empty {
padding: 0.5rem 0;
font-size: 0.6875rem;
color: var(--text-muted);
text-align: center;
}
.hook-add-btn {
background: none;
border: none;
color: #6366f1;
font-size: 0.6875rem;
cursor: pointer;
padding: 0.375rem 0;
width: 100%;
text-align: center;
}
.hook-add-btn:hover {
text-decoration: underline;
}
/* Buttons */
.btn {
padding: 0.3125rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.15s;
}
.btn-xs {
padding: 0.1875rem 0.5rem;
font-size: 0.6875rem;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-primary {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
border-color: #4f46e5;
}
</style>

View File

@@ -0,0 +1,560 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAgentsStore } from '../../stores/agents'
import type { McpServerEntry } from '../../stores/agents'
const store = useAgentsStore()
const showAddModal = ref(false)
const editingServer = ref<string | null>(null)
const newServer = ref<McpServerEntry>({
name: '',
type: 'stdio',
command: '',
args: [],
url: '',
env: {},
enabled: true
})
const argsText = ref('')
const envText = ref('')
function resetForm() {
newServer.value = { name: '', type: 'stdio', command: '', args: [], url: '', env: {}, enabled: true }
argsText.value = ''
envText.value = ''
editingServer.value = null
}
function openAddModal() {
resetForm()
showAddModal.value = true
}
function openEditModal(server: McpServerEntry) {
newServer.value = { ...server, args: [...(server.args || [])], env: { ...(server.env || {}) } }
argsText.value = (server.args || []).join('\n')
envText.value = Object.entries(server.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
editingServer.value = server.name
showAddModal.value = true
}
function saveServer() {
if (!newServer.value.name.trim()) return
// Parse args from text
newServer.value.args = argsText.value.split('\n').map(s => s.trim()).filter(Boolean)
// Parse env from text
const env: Record<string, string> = {}
for (const line of envText.value.split('\n')) {
const eq = line.indexOf('=')
if (eq > 0) {
env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
}
}
newServer.value.env = env
if (editingServer.value) {
// Update existing
const idx = store.mcpServers.findIndex(s => s.name === editingServer.value)
if (idx >= 0) {
store.mcpServers[idx] = { ...newServer.value }
}
} else {
store.addMcpServer({ ...newServer.value })
}
showAddModal.value = false
resetForm()
}
function deleteServer(name: string) {
store.removeMcpServer(name)
}
</script>
<template>
<div class="mcp-manager">
<!-- Header -->
<div class="mcp-header">
<div class="mcp-header-left">
<h3>MCP Servers</h3>
<span class="mcp-count">{{ store.mcpServers.length }} servers</span>
<span class="mcp-path">.mcp.json</span>
</div>
<div class="mcp-header-right">
<button class="btn btn-secondary" @click="openAddModal">+ Add Server</button>
<button
class="btn btn-primary"
:disabled="store.saving"
@click="store.saveMcpServers()"
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
</div>
</div>
<!-- Loading -->
<div v-if="store.mcpsLoading" class="mcp-loading">Loading MCP servers...</div>
<!-- Error -->
<div v-if="store.error" class="mcp-error">{{ store.error }}</div>
<!-- Server cards -->
<div v-if="!store.mcpsLoading" class="mcp-scroll">
<div class="server-grid">
<div v-for="server in store.mcpServers" :key="server.name" class="server-card">
<div class="server-card-header">
<div class="server-info">
<span class="server-name">{{ server.name }}</span>
<span class="server-type-badge" :class="server.type">{{ server.type }}</span>
</div>
<div class="server-actions">
<button
class="toggle-btn"
:class="{ enabled: server.enabled }"
@click="store.toggleMcpServer(server.name)"
:title="server.enabled ? 'Disable' : 'Enable'"
>
<span class="toggle-dot"></span>
</button>
</div>
</div>
<div class="server-card-body">
<div v-if="server.command" class="server-detail">
<span class="detail-label">Command</span>
<code>{{ server.command }}</code>
</div>
<div v-if="server.args?.length" class="server-detail">
<span class="detail-label">Args</span>
<code>{{ server.args.join(' ') }}</code>
</div>
<div v-if="server.url" class="server-detail">
<span class="detail-label">URL</span>
<code>{{ server.url }}</code>
</div>
<div v-if="server.env && Object.keys(server.env).length" class="server-detail">
<span class="detail-label">Env</span>
<span class="env-count">{{ Object.keys(server.env).length }} vars</span>
</div>
</div>
<div class="server-card-footer">
<button class="btn-link" @click="openEditModal(server)">Edit</button>
<button class="btn-link danger" @click="deleteServer(server.name)">Delete</button>
</div>
</div>
</div>
<div v-if="!store.mcpServers.length" class="mcp-empty">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<p>No MCP servers configured</p>
<span>Add a server to get started</span>
</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<div class="modal-header">
<h4>{{ editingServer ? 'Edit Server' : 'Add MCP Server' }}</h4>
<button class="modal-close" @click="showAddModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input type="text" v-model="newServer.name" placeholder="my-server" :disabled="!!editingServer" />
</div>
<div class="form-group">
<label>Type</label>
<select v-model="newServer.type">
<option value="stdio">stdio</option>
<option value="sse">sse</option>
<option value="streamable-http">streamable-http</option>
</select>
</div>
<div v-if="newServer.type === 'stdio'" class="form-group">
<label>Command</label>
<input type="text" v-model="newServer.command" placeholder="npx" />
</div>
<div v-if="newServer.type === 'stdio'" class="form-group">
<label>Args (one per line)</label>
<textarea v-model="argsText" placeholder="arg1&#10;arg2" rows="3"></textarea>
</div>
<div v-if="newServer.type !== 'stdio'" class="form-group">
<label>URL</label>
<input type="text" v-model="newServer.url" placeholder="http://localhost:3000" />
</div>
<div class="form-group">
<label>Environment (KEY=value, one per line)</label>
<textarea v-model="envText" placeholder="API_KEY=xxx" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
<button class="btn btn-primary" @click="saveServer">{{ editingServer ? 'Update' : 'Add' }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.mcp-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mcp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
gap: 1rem;
flex-shrink: 0;
}
.mcp-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.mcp-header-left h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.mcp-count {
font-size: 0.6875rem;
color: var(--text-muted);
padding: 0.125rem 0.5rem;
background: var(--bg-hover);
border-radius: 999px;
}
.mcp-path {
font-size: 0.6875rem;
color: var(--text-muted);
font-family: 'Consolas', monospace;
}
.mcp-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.mcp-loading,
.mcp-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
}
.mcp-empty span {
font-size: 0.6875rem;
opacity: 0.6;
}
.mcp-error {
padding: 0.625rem 1.25rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.8125rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
}
.mcp-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
}
/* Server grid */
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 0.75rem;
}
.server-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.server-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.server-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.server-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.server-type-badge {
font-size: 0.5625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
text-transform: uppercase;
}
.server-type-badge.stdio { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.server-type-badge.sse { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
.server-type-badge.streamable-http { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.server-actions {
display: flex;
gap: 0.375rem;
}
.toggle-btn {
width: 36px;
height: 20px;
border-radius: 10px;
border: none;
background: var(--bg-hover);
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.toggle-btn.enabled {
background: #22c55e;
}
.toggle-dot {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.toggle-btn.enabled .toggle-dot {
transform: translateX(16px);
}
.server-card-body {
padding: 0.625rem 0.75rem;
}
.server-detail {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.1875rem 0;
}
.detail-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
min-width: 60px;
flex-shrink: 0;
}
.server-detail code {
font-size: 0.6875rem;
color: var(--text-secondary);
font-family: 'Consolas', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-count {
font-size: 0.6875rem;
color: var(--text-muted);
}
.server-card-footer {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-color);
}
.btn-link {
background: none;
border: none;
color: #6366f1;
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.btn-link:hover {
text-decoration: underline;
}
.btn-link.danger {
color: #ef4444;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.25rem;
cursor: pointer;
padding: 0 0.25rem;
line-height: 1;
}
.modal-body {
padding: 1rem;
overflow-y: auto;
}
.form-group {
margin-bottom: 0.75rem;
}
.form-group label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
font-family: 'Consolas', monospace;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #6366f1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color);
}
/* Buttons */
.btn {
padding: 0.3125rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-primary {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
border-color: #4f46e5;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { useAgentsStore } from '../../stores/agents'
const store = useAgentsStore()
</script>
<template>
<div class="plugins-manager">
<!-- Header -->
<div class="plugins-header">
<div class="plugins-header-left">
<h3>Global Plugins</h3>
<span class="plugins-count">{{ store.plugins.length }} plugins</span>
<span class="plugins-path">~/.claude/plugins/marketplaces/</span>
</div>
</div>
<!-- Loading -->
<div v-if="store.pluginsLoading" class="plugins-loading">Loading plugins...</div>
<!-- Error -->
<div v-if="store.error" class="plugins-error">{{ store.error }}</div>
<!-- Plugin cards -->
<div v-if="!store.pluginsLoading" class="plugins-scroll">
<div class="plugin-grid">
<div v-for="plugin in store.plugins" :key="plugin.name" class="plugin-card">
<div class="plugin-card-header">
<div class="plugin-info">
<span class="plugin-name">{{ plugin.name }}</span>
<span class="plugin-installed" v-if="plugin.installed">Installed</span>
</div>
</div>
<div class="plugin-card-body">
<p v-if="plugin.description" class="plugin-desc">{{ plugin.description }}</p>
<div v-if="plugin.author" class="plugin-meta">
<span class="meta-label">Author</span>
<span class="meta-value">{{ plugin.author }}</span>
</div>
<div v-if="plugin.mcpConfig" class="plugin-meta">
<span class="meta-label">MCP</span>
<span class="meta-value">{{ typeof plugin.mcpConfig === 'object' ? JSON.stringify(plugin.mcpConfig).slice(0, 80) : plugin.mcpConfig }}</span>
</div>
</div>
</div>
</div>
<div v-if="!store.plugins.length" class="plugins-empty">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
<p>No plugins installed</p>
<span>Plugins from ~/.claude/plugins/marketplaces/ will appear here</span>
</div>
</div>
</div>
</template>
<style scoped>
.plugins-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.plugins-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.plugins-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.plugins-header-left h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.plugins-count {
font-size: 0.6875rem;
color: var(--text-muted);
padding: 0.125rem 0.5rem;
background: var(--bg-hover);
border-radius: 999px;
}
.plugins-path {
font-size: 0.6875rem;
color: var(--text-muted);
font-family: 'Consolas', monospace;
}
.plugins-loading,
.plugins-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
}
.plugins-empty span {
font-size: 0.6875rem;
opacity: 0.6;
}
.plugins-error {
padding: 0.625rem 1.25rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.8125rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
}
.plugins-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
}
.plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.75rem;
}
.plugin-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.plugin-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.plugin-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.plugin-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.plugin-installed {
font-size: 0.5625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
text-transform: uppercase;
}
.plugin-card-body {
padding: 0.625rem 0.75rem;
}
.plugin-desc {
margin: 0 0 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
line-height: 1.4;
}
.plugin-meta {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.125rem 0;
}
.meta-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
min-width: 50px;
flex-shrink: 0;
}
.meta-value {
font-size: 0.6875rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { useAgentsStore } from '../../stores/agents'
import type { SkillEntry } from '../../stores/agents'
const store = useAgentsStore()
function selectSkill(skill: SkillEntry) {
store.selectedSkill = skill
}
</script>
<template>
<div class="skills-manager">
<!-- Header -->
<div class="skills-header">
<h3>Skills</h3>
<span class="skills-count">{{ store.skills.length }} skills</span>
</div>
<!-- Loading -->
<div v-if="store.skillsLoading" class="skills-loading">Loading skills...</div>
<!-- Error -->
<div v-if="store.error" class="skills-error">{{ store.error }}</div>
<!-- Two-column layout -->
<div v-if="!store.skillsLoading" class="skills-layout">
<!-- Left: skills list -->
<aside class="skills-list">
<button
v-for="skill in store.skills"
:key="skill.name"
class="skill-item"
:class="{ active: store.selectedSkill?.name === skill.name }"
@click="selectSkill(skill)"
>
<div class="skill-item-main">
<span class="skill-name">{{ skill.name }}</span>
<span v-if="skill.references.length" class="skill-refs">{{ skill.references.length }} files</span>
</div>
<p v-if="skill.description" class="skill-desc">{{ skill.description }}</p>
</button>
<div v-if="!store.skills.length" class="skills-empty-list">
<p>No skills found</p>
<span>Add a SKILL.md to .claude/skills/*/</span>
</div>
</aside>
<!-- Right: skill detail -->
<main class="skill-detail">
<template v-if="store.selectedSkill">
<div class="skill-detail-header">
<h4>{{ store.selectedSkill.name }}</h4>
<span class="skill-detail-path" :title="store.selectedSkill.path">{{ store.selectedSkill.path }}</span>
</div>
<!-- SKILL.md content -->
<div class="skill-md-content">
<pre>{{ store.selectedSkill.skillMdContent }}</pre>
</div>
<!-- References -->
<div v-if="store.selectedSkill.references.length" class="skill-references">
<h5>Referenced Files</h5>
<div class="ref-list">
<div v-for="ref in store.selectedSkill.references" :key="ref.name" class="ref-item">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
</svg>
<span>{{ ref.name }}</span>
</div>
</div>
</div>
</template>
<div v-else class="skill-detail-empty">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<p>Select a skill to view details</p>
</div>
</main>
</div>
</div>
</template>
<style scoped>
.skills-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.skills-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.skills-header h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.skills-count {
font-size: 0.6875rem;
color: var(--text-muted);
padding: 0.125rem 0.5rem;
background: var(--bg-hover);
border-radius: 999px;
}
.skills-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.8125rem;
}
.skills-error {
padding: 0.625rem 1.25rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.8125rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
}
/* Two-column layout */
.skills-layout {
flex: 1;
display: flex;
overflow: hidden;
}
.skills-list {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
background: var(--bg-secondary);
}
.skill-item {
width: 100%;
display: block;
padding: 0.625rem 0.875rem;
background: none;
border: none;
border-bottom: 1px solid var(--border-color);
text-align: left;
cursor: pointer;
transition: background 0.12s;
}
.skill-item:hover {
background: var(--bg-hover);
}
.skill-item.active {
background: rgba(99, 102, 241, 0.08);
}
.skill-item-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.skill-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
}
.skill-refs {
font-size: 0.625rem;
color: var(--text-muted);
padding: 0.0625rem 0.375rem;
background: var(--bg-hover);
border-radius: 999px;
flex-shrink: 0;
}
.skill-desc {
margin: 0.25rem 0 0;
font-size: 0.6875rem;
color: var(--text-muted);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skills-empty-list {
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
}
.skills-empty-list p {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
}
.skills-empty-list span {
font-size: 0.6875rem;
opacity: 0.6;
}
/* Skill detail */
.skill-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary);
}
.skill-detail-header {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.skill-detail-header h4 {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.skill-detail-path {
font-size: 0.6875rem;
color: var(--text-muted);
font-family: 'Consolas', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.skill-md-content {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
}
.skill-md-content pre {
margin: 0;
font-size: 0.8125rem;
font-family: 'Consolas', 'Monaco', monospace;
color: var(--text-primary);
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
.skill-references {
border-top: 1px solid var(--border-color);
padding: 0.75rem 1.25rem;
background: var(--bg-secondary);
}
.skill-references h5 {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
}
.ref-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ref-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.ref-item svg {
flex-shrink: 0;
color: var(--text-muted);
}
.skill-detail-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--text-muted);
}
.skill-detail-empty p {
font-size: 0.875rem;
margin: 0;
}
</style>

View File

@@ -0,0 +1,484 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAgentsStore } from '../../stores/agents'
import type { ToolEntry } from '../../stores/agents'
const store = useAgentsStore()
const newRuleFor = ref<string | null>(null)
const newRuleValue = ref('')
function statusColor(status: string): string {
if (status === 'allow') return '#22c55e'
if (status === 'deny') return '#ef4444'
return 'var(--text-muted)'
}
function statusLabel(status: string): string {
if (status === 'allow') return 'Allow'
if (status === 'deny') return 'Deny'
return 'Ask'
}
function submitRule(toolKey: string) {
if (newRuleValue.value.trim()) {
store.addToolRule(toolKey, newRuleValue.value.trim())
newRuleValue.value = ''
newRuleFor.value = null
}
}
function isParameterizable(name: string): boolean {
return ['Bash', 'WebFetch', 'Skill', 'WebSearch'].includes(name)
}
</script>
<template>
<div class="tools-manager">
<!-- Header -->
<div class="tools-header">
<div class="tools-header-left">
<h3>Tool Permissions</h3>
<span class="tools-count">{{ store.filteredTools.length }} tools</span>
<span v-if="store.configFile" class="config-path">{{ store.configFile }}</span>
</div>
<div class="tools-header-right">
<input
type="text"
class="tools-search"
placeholder="Filter tools..."
v-model="store.toolsFilter"
/>
<button
class="btn btn-primary"
:disabled="!store.configDirty || store.saving"
@click="store.savePermissions()"
>{{ store.saving ? 'Saving...' : 'Save Permissions' }}</button>
</div>
</div>
<!-- Loading -->
<div v-if="store.toolsLoading" class="tools-loading">Loading tools...</div>
<!-- Error -->
<div v-if="store.error" class="tools-error">{{ store.error }}</div>
<!-- Tools list -->
<div v-if="!store.toolsLoading" class="tools-scroll">
<!-- Base tools -->
<div v-if="store.toolsByCategory.base.length" class="tool-group">
<div class="tool-group-header">
<span class="tool-group-label">Base Tools</span>
<span class="tool-group-count">{{ store.toolsByCategory.base.length }}</span>
</div>
<div class="tool-cards">
<div v-for="tool in store.toolsByCategory.base" :key="tool.fullKey" class="tool-card">
<div class="tool-card-main">
<div class="tool-info">
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-badge base">base</span>
</div>
<button
class="status-toggle"
:style="{ '--status-color': statusColor(tool.status) }"
@click="store.cycleToolStatus(tool.fullKey)"
:title="`Click to cycle: ${tool.status}`"
>
<span class="status-dot" :class="tool.status"></span>
<span class="status-text">{{ statusLabel(tool.status) }}</span>
</button>
</div>
<!-- Rules for parameterizable tools -->
<div v-if="isParameterizable(tool.name) && tool.status !== 'ask'" class="tool-rules">
<div v-for="(rule, idx) in tool.rules" :key="idx" class="rule-item">
<code>{{ tool.name }}({{ rule }})</code>
<button class="rule-remove" @click="store.removeToolRule(tool.fullKey, rule)">&times;</button>
</div>
<div v-if="newRuleFor === tool.fullKey" class="rule-add-form">
<input
type="text"
class="rule-input"
v-model="newRuleValue"
placeholder="e.g. git:* or domain:example.com"
@keydown.enter="submitRule(tool.fullKey)"
@keydown.escape="newRuleFor = null"
/>
<button class="btn-sm" @click="submitRule(tool.fullKey)">Add</button>
</div>
<button v-else class="rule-add-btn" @click="newRuleFor = tool.fullKey; newRuleValue = ''">
+ Add rule
</button>
</div>
</div>
</div>
</div>
<!-- MCP tools grouped by server -->
<div
v-for="(tools, server) in store.toolsByCategory.mcpGroups"
:key="server"
class="tool-group"
>
<div class="tool-group-header">
<span class="tool-group-label">MCP: {{ server }}</span>
<span class="tool-group-count">{{ tools.length }}</span>
</div>
<div class="tool-cards">
<div v-for="tool in tools" :key="tool.fullKey" class="tool-card">
<div class="tool-card-main">
<div class="tool-info">
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-badge mcp">mcp</span>
<span v-if="tool.host" class="tool-host">{{ tool.host }}</span>
</div>
<button
class="status-toggle"
:style="{ '--status-color': statusColor(tool.status) }"
@click="store.cycleToolStatus(tool.fullKey)"
:title="`Click to cycle: ${tool.status}`"
>
<span class="status-dot" :class="tool.status"></span>
<span class="status-text">{{ statusLabel(tool.status) }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="!store.toolsByCategory.base.length && !Object.keys(store.toolsByCategory.mcpGroups).length" class="tools-empty">
<p>No tools found</p>
<span>Tool permissions will appear here when configured</span>
</div>
</div>
</div>
</template>
<style scoped>
.tools-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tools-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
gap: 1rem;
flex-shrink: 0;
}
.tools-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tools-header-left h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.tools-count {
font-size: 0.6875rem;
color: var(--text-muted);
padding: 0.125rem 0.5rem;
background: var(--bg-hover);
border-radius: 999px;
}
.config-path {
font-size: 0.6875rem;
color: var(--text-muted);
font-family: 'Consolas', monospace;
}
.tools-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tools-search {
padding: 0.3125rem 0.75rem;
font-size: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
width: 200px;
}
.tools-search:focus {
border-color: #6366f1;
}
.tools-loading,
.tools-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.8125rem;
}
.tools-empty span {
font-size: 0.6875rem;
opacity: 0.6;
}
.tools-error {
padding: 0.625rem 1.25rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.8125rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
}
.tools-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
}
/* Tool groups */
.tool-group {
margin-bottom: 1.5rem;
}
.tool-group-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tool-group-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.tool-group-count {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: var(--bg-hover);
border-radius: 999px;
color: var(--text-muted);
}
/* Tool cards */
.tool-cards {
display: flex;
flex-direction: column;
gap: 4px;
}
.tool-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.tool-card-main {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
gap: 0.5rem;
}
.tool-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.tool-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
font-family: 'Consolas', monospace;
}
.tool-badge {
font-size: 0.5625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
text-transform: uppercase;
}
.tool-badge.base {
background: rgba(99, 102, 241, 0.12);
color: #6366f1;
}
.tool-badge.mcp {
background: rgba(139, 92, 246, 0.12);
color: #8b5cf6;
}
.tool-host {
font-size: 0.625rem;
color: var(--text-muted);
font-family: 'Consolas', monospace;
}
/* Status toggle */
.status-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.status-toggle:hover {
background: var(--bg-hover);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: background 0.15s;
}
.status-dot.allow { background: #22c55e; }
.status-dot.deny { background: #ef4444; }
.status-dot.ask { background: var(--text-muted); }
.status-text {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-secondary);
}
/* Rules */
.tool-rules {
padding: 0.375rem 0.75rem 0.5rem;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
}
.rule-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.1875rem 0;
}
.rule-item code {
font-size: 0.6875rem;
color: var(--text-secondary);
font-family: 'Consolas', monospace;
}
.rule-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.875rem;
padding: 0 0.25rem;
line-height: 1;
}
.rule-remove:hover {
color: #ef4444;
}
.rule-add-form {
display: flex;
gap: 0.375rem;
margin-top: 0.25rem;
}
.rule-input {
flex: 1;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
font-family: 'Consolas', monospace;
}
.rule-input:focus {
border-color: #6366f1;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
background: #6366f1;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.rule-add-btn {
background: none;
border: none;
color: #6366f1;
font-size: 0.6875rem;
cursor: pointer;
padding: 0.25rem 0;
}
.rule-add-btn:hover {
text-decoration: underline;
}
.btn {
padding: 0.3125rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
border-color: #4f46e5;
}
</style>

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { BranchInfo } from '@/types/git'
const props = defineProps<{
branches: BranchInfo[]
selected: string
label?: string
}>()
const emit = defineEmits<{
select: [branch: string]
}>()
const isOpen = ref(false)
const search = ref('')
const dropdownRef = ref<HTMLElement | null>(null)
const filteredBranches = computed(() => {
const q = search.value.toLowerCase()
return props.branches.filter(b => b.name.toLowerCase().includes(q))
})
const localBranches = computed(() => filteredBranches.value.filter(b => !b.isRemote))
const remoteBranches = computed(() => filteredBranches.value.filter(b => b.isRemote))
function selectBranch(name: string) {
emit('select', name)
isOpen.value = false
search.value = ''
}
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div ref="dropdownRef" class="branch-selector">
<label v-if="label" class="selector-label">{{ label }}</label>
<button class="selector-button" @click="isOpen = !isOpen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<span class="selected-branch">{{ selected || 'Select branch' }}</span>
<svg class="chevron" :class="{ open: isOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div v-if="isOpen" class="dropdown">
<input
v-model="search"
type="text"
class="search-input"
placeholder="Filter branches..."
@click.stop
/>
<div class="branch-list">
<div v-if="localBranches.length > 0" class="branch-group">
<div class="group-header">Local</div>
<div
v-for="branch in localBranches"
:key="branch.name"
:class="['branch-item', { current: branch.isCurrent, selected: branch.name === selected }]"
@click="selectBranch(branch.name)"
>
<span v-if="branch.isCurrent" class="current-marker"></span>
{{ branch.name }}
</div>
</div>
<div v-if="remoteBranches.length > 0" class="branch-group">
<div class="group-header">Remote</div>
<div
v-for="branch in remoteBranches"
:key="branch.name"
:class="['branch-item', { selected: branch.name === selected }]"
@click="selectBranch(branch.name)"
>
{{ branch.name }}
</div>
</div>
<div v-if="filteredBranches.length === 0" class="no-results">
No branches found
</div>
</div>
</div>
</div>
</template>
<style scoped>
.branch-selector {
position: relative;
}
.selector-label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.selector-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
min-width: 180px;
transition: all 0.15s;
}
.selector-button:hover {
background: var(--bg-hover);
border-color: var(--accent);
}
.selected-branch {
flex: 1;
text-align: left;
font-size: 13px;
}
.chevron {
transition: transform 0.15s;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 100;
overflow: hidden;
}
.search-input {
width: 100%;
padding: 0.75rem;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.branch-list {
max-height: 300px;
overflow-y: auto;
}
.branch-group {
padding: 0.5rem 0;
}
.group-header {
padding: 0.25rem 0.75rem;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.branch-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.branch-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.branch-item.selected {
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
}
.branch-item.current {
color: var(--text-primary);
}
.current-marker {
color: #22c55e;
font-size: 8px;
}
.no-results {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.selector-button {
min-width: 100%;
padding: 0.5rem;
}
.selected-branch {
font-size: 12px;
}
.dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.branch-list {
max-height: 50vh;
}
.branch-item {
padding: 0.75rem 1rem;
}
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CommitInfo } from '@/types/git'
const props = defineProps<{
commits: CommitInfo[]
selectedSha?: string | null
loading?: boolean
}>()
const emit = defineEmits<{
select: [sha: string]
loadMore: []
}>()
function formatDate(timestamp: number) {
const date = new Date(timestamp * 1000)
const now = new Date()
const diff = now.getTime() - date.getTime()
// Less than 1 day
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
if (hours < 1) {
const mins = Math.floor(diff / 60000)
return mins < 1 ? 'just now' : `${mins}m ago`
}
return `${hours}h ago`
}
// Less than 7 days
if (diff < 604800000) {
const days = Math.floor(diff / 86400000)
return `${days}d ago`
}
// Format as date
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
function getInitials(name: string) {
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
function getAvatarColor(email: string) {
let hash = 0
for (let i = 0; i < email.length; i++) {
hash = email.charCodeAt(i) + ((hash << 5) - hash)
}
const hue = hash % 360
return `hsl(${hue}, 60%, 45%)`
}
</script>
<template>
<div class="commit-list">
<div v-if="commits.length === 0 && !loading" class="empty-state">
Sin commits
</div>
<div
v-for="commit in commits"
:key="commit.sha"
:class="['commit-item', { selected: selectedSha === commit.sha }]"
@click="emit('select', commit.sha)"
>
<div class="commit-avatar" :style="{ background: getAvatarColor(commit.email) }">
{{ getInitials(commit.author) }}
</div>
<div class="commit-info">
<div class="commit-message">{{ commit.message }}</div>
<div class="commit-meta">
<span class="commit-sha">{{ commit.shortSha }}</span>
<span class="commit-author">{{ commit.author }}</span>
<span class="commit-date">{{ formatDate(commit.timestamp) }}</span>
</div>
</div>
</div>
<div v-if="loading" class="loading">
<div class="spinner"></div>
Cargando...
</div>
<button
v-else-if="commits.length > 0"
class="load-more"
@click="emit('loadMore')"
>
Cargar más
</button>
</div>
</template>
<style scoped>
.commit-list {
display: flex;
flex-direction: column;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.commit-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background 0.15s;
}
.commit-item:hover {
background: var(--bg-hover);
}
.commit-item.selected {
background: rgba(99, 102, 241, 0.1);
border-left: 2px solid var(--accent);
}
.commit-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.commit-info {
flex: 1;
min-width: 0;
}
.commit-message {
color: var(--text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.25rem;
}
.commit-meta {
display: flex;
gap: 0.75rem;
font-size: 12px;
color: var(--text-muted);
}
.commit-sha {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--accent);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
color: var(--text-muted);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.load-more {
margin: 1rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.load-more:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 768px) {
.commit-item {
padding: 0.5rem 0.75rem;
gap: 0.5rem;
}
.commit-avatar {
width: 28px;
height: 28px;
font-size: 10px;
}
.commit-message {
font-size: 12px;
}
.commit-meta {
font-size: 11px;
gap: 0.5rem;
flex-wrap: wrap;
}
.commit-meta .commit-author {
display: none;
}
.load-more {
margin: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileDiff, DiffHunk } from '@/types/git'
const props = defineProps<{
files: FileDiff[]
expandedFiles?: Set<string>
}>()
const emit = defineEmits<{
toggleFile: [path: string]
}>()
function isExpanded(path: string): boolean {
return props.expandedFiles?.has(path) ?? true
}
function getStatusBadge(status: string) {
const badges: Record<string, { label: string; class: string }> = {
added: { label: 'A', class: 'badge-added' },
modified: { label: 'M', class: 'badge-modified' },
deleted: { label: 'D', class: 'badge-deleted' },
renamed: { label: 'R', class: 'badge-renamed' }
}
return badges[status] || { label: '?', class: '' }
}
function getLineNumbers(hunk: DiffHunk) {
let oldLine = hunk.oldStart
let newLine = hunk.newStart
return hunk.lines.map(line => {
const result = {
old: line.type === 'add' ? '' : oldLine,
new: line.type === 'delete' ? '' : newLine
}
if (line.type !== 'add') oldLine++
if (line.type !== 'delete') newLine++
return result
})
}
</script>
<template>
<div class="diff-viewer">
<div v-if="files.length === 0" class="empty-state">
No hay cambios para mostrar
</div>
<div v-for="file in files" :key="file.path" class="file-diff">
<div class="file-header" @click="emit('toggleFile', file.path)">
<span :class="['status-badge', getStatusBadge(file.status).class]">
{{ getStatusBadge(file.status).label }}
</span>
<span class="file-path">{{ file.path }}</span>
<span v-if="file.oldPath" class="old-path">
(from {{ file.oldPath }})
</span>
<svg v-if="file.isBinary" class="binary-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M8 7v10M12 7v10M16 7v10" />
</svg>
<span class="toggle-icon">
{{ isExpanded(file.path) ? '▼' : '▶' }}
</span>
</div>
<div v-if="isExpanded(file.path) && !file.isBinary" class="file-content">
<div v-for="(hunk, hunkIdx) in file.hunks" :key="hunkIdx" class="hunk">
<div class="hunk-header">
@@ -{{ hunk.oldStart }},{{ hunk.oldLines }} +{{ hunk.newStart }},{{ hunk.newLines }} @@
<span v-if="hunk.header" class="hunk-context">{{ hunk.header }}</span>
</div>
<div class="hunk-lines">
<div
v-for="(line, lineIdx) in hunk.lines"
:key="lineIdx"
:class="['diff-line', `line-${line.type}`]"
>
<span class="line-number old">{{ getLineNumbers(hunk)[lineIdx].old }}</span>
<span class="line-number new">{{ getLineNumbers(hunk)[lineIdx].new }}</span>
<span class="line-prefix">{{ line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' ' }}</span>
<span class="line-content">{{ line.content }}</span>
</div>
</div>
</div>
</div>
<div v-else-if="file.isBinary" class="binary-notice">
Archivo binario - no se puede mostrar diff
</div>
</div>
</div>
</template>
<style scoped>
.diff-viewer {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.file-diff {
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}
.file-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
cursor: pointer;
user-select: none;
}
.file-header:hover {
background: var(--bg-hover);
}
.status-badge {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-added {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.badge-modified {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.badge-deleted {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.badge-renamed {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.file-path {
color: var(--text-primary);
flex: 1;
}
.old-path {
color: var(--text-muted);
font-size: 12px;
}
.binary-icon {
color: var(--text-muted);
}
.toggle-icon {
color: var(--text-muted);
font-size: 10px;
}
.file-content {
background: var(--bg-primary);
}
.hunk {
border-top: 1px solid var(--border-color);
}
.hunk-header {
padding: 0.5rem 1rem;
background: var(--bg-hover);
color: var(--accent);
font-size: 12px;
}
.hunk-context {
color: var(--text-muted);
margin-left: 0.5rem;
}
.hunk-lines {
overflow-x: auto;
}
.diff-line {
display: flex;
white-space: pre;
}
.line-number {
min-width: 45px;
padding: 0 0.5rem;
text-align: right;
color: var(--text-muted);
background: var(--bg-secondary);
user-select: none;
border-right: 1px solid var(--border-color);
}
.line-number.old {
border-right: none;
}
.line-prefix {
width: 20px;
text-align: center;
user-select: none;
}
.line-content {
flex: 1;
padding-right: 1rem;
}
.line-context {
background: transparent;
}
.line-add {
background: rgba(34, 197, 94, 0.1);
}
.line-add .line-prefix,
.line-add .line-content {
color: #22c55e;
}
.line-add .line-number.new {
background: rgba(34, 197, 94, 0.15);
}
.line-delete {
background: rgba(239, 68, 68, 0.1);
}
.line-delete .line-prefix,
.line-delete .line-content {
color: #ef4444;
}
.line-delete .line-number.old {
background: rgba(239, 68, 68, 0.15);
}
.binary-notice {
padding: 1rem;
color: var(--text-muted);
text-align: center;
font-style: italic;
}
@media (max-width: 768px) {
.diff-viewer {
font-size: 11px;
}
.file-header {
padding: 0.5rem 0.75rem;
}
.file-path {
font-size: 12px;
word-break: break-all;
}
.status-badge {
width: 18px;
height: 18px;
font-size: 10px;
}
.hunk-header {
padding: 0.4rem 0.75rem;
font-size: 11px;
}
.line-number {
min-width: 32px;
padding: 0 0.25rem;
font-size: 10px;
}
.line-prefix {
width: 16px;
}
.line-content {
padding-right: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileChange } from '@/types/git'
const props = defineProps<{
staged: FileChange[]
unstaged: FileChange[]
untracked: string[]
selectedFile?: string | null
}>()
const emit = defineEmits<{
select: [path: string, staged: boolean]
}>()
function getStatusIcon(status: string) {
const icons: Record<string, { icon: string; class: string }> = {
added: { icon: 'A', class: 'status-added' },
modified: { icon: 'M', class: 'status-modified' },
deleted: { icon: 'D', class: 'status-deleted' },
renamed: { icon: 'R', class: 'status-renamed' },
untracked: { icon: '?', class: 'status-untracked' }
}
return icons[status] || { icon: '?', class: '' }
}
const hasChanges = computed(() => {
return props.staged.length > 0 || props.unstaged.length > 0 || props.untracked.length > 0
})
</script>
<template>
<div class="file-tree">
<div v-if="!hasChanges" class="empty-state">
Sin cambios
</div>
<!-- Staged changes -->
<div v-if="staged.length > 0" class="section">
<div class="section-header">
<span class="section-icon staged"></span>
Staged ({{ staged.length }})
</div>
<div
v-for="file in staged"
:key="'staged-' + file.path"
:class="['file-item', { selected: selectedFile === file.path }]"
@click="emit('select', file.path, true)"
>
<span :class="['status-icon', getStatusIcon(file.status).class]">
{{ getStatusIcon(file.status).icon }}
</span>
<span class="file-name">{{ file.path }}</span>
</div>
</div>
<!-- Unstaged changes -->
<div v-if="unstaged.length > 0" class="section">
<div class="section-header">
<span class="section-icon unstaged"></span>
Unstaged ({{ unstaged.length }})
</div>
<div
v-for="file in unstaged"
:key="'unstaged-' + file.path"
:class="['file-item', { selected: selectedFile === file.path }]"
@click="emit('select', file.path, false)"
>
<span :class="['status-icon', getStatusIcon(file.status).class]">
{{ getStatusIcon(file.status).icon }}
</span>
<span class="file-name">{{ file.path }}</span>
</div>
</div>
<!-- Untracked files -->
<div v-if="untracked.length > 0" class="section">
<div class="section-header">
<span class="section-icon untracked"></span>
Untracked ({{ untracked.length }})
</div>
<div
v-for="path in untracked"
:key="'untracked-' + path"
:class="['file-item', { selected: selectedFile === path }]"
@click="emit('select', path, false)"
>
<span :class="['status-icon', 'status-untracked']">?</span>
<span class="file-name">{{ path }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.file-tree {
font-size: 13px;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 1rem;
}
.section {
margin-bottom: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.section-icon {
font-size: 8px;
}
.section-icon.staged {
color: #22c55e;
}
.section-icon.unstaged {
color: #eab308;
}
.section-icon.untracked {
color: var(--text-muted);
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.file-item:hover {
background: var(--bg-hover);
}
.file-item.selected {
background: var(--accent);
background: rgba(99, 102, 241, 0.15);
}
.status-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
border-radius: 3px;
}
.status-added {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-modified {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.status-deleted {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.status-renamed {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.status-untracked {
background: rgba(161, 161, 170, 0.2);
color: var(--text-muted);
}
.file-name {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileContent } from '@/types/git'
const props = defineProps<{
file: FileContent | null
loading?: boolean
}>()
const emit = defineEmits<{
close: []
}>()
// Get language for syntax highlighting hint
const language = computed(() => {
if (!props.file?.extension) return 'text'
const langMap: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
vue: 'vue',
json: 'json',
md: 'markdown',
css: 'css',
scss: 'scss',
html: 'html',
svg: 'xml',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
sh: 'bash',
bash: 'bash',
py: 'python',
rs: 'rust',
go: 'go',
sql: 'sql'
}
return langMap[props.file.extension] || 'text'
})
const lines = computed(() => {
if (!props.file?.content) return []
return props.file.content.split('\n')
})
const fileName = computed(() => {
if (!props.file?.path) return ''
return props.file.path.split('/').pop() || props.file.path
})
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="file-viewer">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
Cargando archivo...
</div>
<template v-else-if="file">
<div class="file-header">
<div class="file-info">
<span class="file-name">{{ fileName }}</span>
<span class="file-path">{{ file.path }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
</div>
<button class="close-btn" @click="emit('close')" title="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div v-if="file.isBinary" class="binary-notice">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Archivo binario - no se puede mostrar el contenido</p>
<span>{{ formatSize(file.size) }}</span>
</div>
<div v-else class="code-container">
<div class="line-numbers">
<span v-for="(_, i) in lines" :key="i" class="line-number">{{ i + 1 }}</span>
</div>
<pre class="code-content"><code>{{ file.content }}</code></pre>
</div>
</template>
<div v-else class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Selecciona un archivo para ver su contenido</p>
</div>
</div>
</template>
<style scoped>
.file-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex: 1;
padding: 2rem;
color: var(--text-muted);
text-align: center;
}
.empty-state svg {
opacity: 0.5;
}
.empty-state p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.file-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.file-name {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.file-path {
font-size: 12px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 11px;
color: var(--text-muted);
padding: 0.15rem 0.4rem;
background: var(--bg-hover);
border-radius: 4px;
flex-shrink: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.binary-notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex: 1;
padding: 2rem;
color: var(--text-muted);
text-align: center;
}
.binary-notice p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.binary-notice span {
font-size: 12px;
}
.code-container {
display: flex;
flex: 1;
overflow: auto;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 0.75rem 0;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
text-align: right;
user-select: none;
flex-shrink: 0;
}
.line-number {
padding: 0 0.75rem;
color: var(--text-muted);
font-size: 12px;
min-width: 3ch;
}
.code-content {
flex: 1;
margin: 0;
padding: 0.75rem 1rem;
overflow-x: auto;
white-space: pre;
color: var(--text-primary);
}
.code-content code {
font-family: inherit;
}
/* Mobile */
@media (max-width: 768px) {
.file-header {
padding: 0.5rem 0.75rem;
}
.file-info {
gap: 0.5rem;
}
.file-name {
font-size: 12px;
}
.file-path {
display: none;
}
.code-container {
font-size: 11px;
line-height: 1.5;
}
.line-number {
font-size: 10px;
padding: 0 0.5rem;
}
.code-content {
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,290 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { TreeNode } from '@/types/git'
const props = defineProps<{
nodes: TreeNode[]
selectedPath?: string | null
loading?: boolean
}>()
const emit = defineEmits<{
select: [path: string]
}>()
const expandedDirs = ref<Set<string>>(new Set())
function toggleDir(path: string) {
if (expandedDirs.value.has(path)) {
expandedDirs.value.delete(path)
} else {
expandedDirs.value.add(path)
}
expandedDirs.value = new Set(expandedDirs.value)
}
function isExpanded(path: string): boolean {
return expandedDirs.value.has(path)
}
function handleSelect(node: TreeNode) {
if (node.type === 'directory') {
toggleDir(node.path)
} else {
emit('select', node.path)
}
}
function getFileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || ''
const icons: Record<string, string> = {
ts: 'ts',
tsx: 'tsx',
js: 'js',
jsx: 'jsx',
vue: 'vue',
json: 'json',
md: 'md',
css: 'css',
scss: 'scss',
html: 'html',
svg: 'svg',
png: 'img',
jpg: 'img',
jpeg: 'img',
gif: 'img',
ico: 'img',
gitignore: 'git',
env: 'env',
yml: 'yml',
yaml: 'yml',
toml: 'toml',
lock: 'lock'
}
return icons[ext] || 'file'
}
</script>
<template>
<div class="project-tree">
<div v-if="loading" class="loading">
<div class="spinner"></div>
Cargando archivos...
</div>
<div v-else-if="nodes.length === 0" class="empty">
Sin archivos
</div>
<template v-else>
<div
v-for="node in nodes"
:key="node.path"
class="tree-node"
>
<div
:class="['node-row', { selected: selectedPath === node.path }]"
@click="handleSelect(node)"
>
<!-- Directory -->
<template v-if="node.type === 'directory'">
<span class="expand-icon">
<svg
:class="{ expanded: isExpanded(node.path) }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 5l8 7-8 7z" />
</svg>
</span>
<span class="folder-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path v-if="isExpanded(node.path)" d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2z" />
<path v-else d="M3 6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
</svg>
</span>
</template>
<!-- File -->
<template v-else>
<span class="expand-icon spacer"></span>
<span :class="['file-icon', getFileIcon(node.name)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
</template>
<span class="node-name">{{ node.name }}</span>
</div>
<!-- Recursive children -->
<div v-if="node.type === 'directory' && isExpanded(node.path) && node.children" class="children">
<ProjectTree
:nodes="node.children"
:selected-path="selectedPath"
@select="emit('select', $event)"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.project-tree {
font-size: 13px;
user-select: none;
}
.loading,
.empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem;
color: var(--text-muted);
font-size: 12px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.tree-node {
/* Container for each node */
}
.node-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.node-row:hover {
background: var(--bg-hover);
}
.node-row.selected {
background: rgba(99, 102, 241, 0.15);
}
.expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.15s;
}
.expand-icon.spacer {
visibility: hidden;
}
.expand-icon svg {
transition: transform 0.15s;
}
.expand-icon svg.expanded {
transform: rotate(90deg);
}
.folder-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #f59e0b;
}
.file-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
}
/* File type colors */
.file-icon.ts,
.file-icon.tsx {
color: #3178c6;
}
.file-icon.js,
.file-icon.jsx {
color: #f7df1e;
}
.file-icon.vue {
color: #42b883;
}
.file-icon.json {
color: #f5a623;
}
.file-icon.md {
color: #519aba;
}
.file-icon.css,
.file-icon.scss {
color: #563d7c;
}
.file-icon.html {
color: #e34c26;
}
.file-icon.svg,
.file-icon.img {
color: #a855f7;
}
.file-icon.git {
color: #f05032;
}
.file-icon.env {
color: #ecd53f;
}
.file-icon.yml {
color: #cb171e;
}
.file-icon.lock {
color: #6b7280;
}
.node-name {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.children {
padding-left: 1rem;
}
</style>

View File

@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
interface StatusFile {
path: string
status: string
}
interface StatusNode {
name: string
path: string
type: 'file' | 'directory'
status?: string
children?: StatusNode[]
fileCount?: number
}
const props = defineProps<{
files?: StatusFile[]
nodes?: StatusNode[]
selectedPath?: string | null
depth?: number
}>()
const emit = defineEmits<{
select: [path: string]
}>()
const expandedDirs = ref<Set<string>>(new Set())
function countFiles(nodes: StatusNode[]): number {
let count = 0
for (const n of nodes) {
if (n.type === 'file') count++
else if (n.children) count += countFiles(n.children)
}
return count
}
function buildTree(files: StatusFile[]): StatusNode[] {
const root: Record<string, any> = {}
for (const file of files) {
const parts = file.path.split('/')
let current = root
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i === parts.length - 1) {
current[part] = { __file: true, status: file.status, path: file.path }
} else {
if (!current[part] || current[part].__file) {
current[part] = {}
}
current = current[part]
}
}
}
function toNodes(obj: Record<string, any>, parentPath: string): StatusNode[] {
const dirs: StatusNode[] = []
const fileNodes: StatusNode[] = []
for (const [name, value] of Object.entries(obj)) {
if (value.__file) {
fileNodes.push({
name,
path: value.path,
type: 'file',
status: value.status
})
} else {
const dirPath = parentPath ? `${parentPath}/${name}` : name
const children = toNodes(value, dirPath)
const fileCount = countFiles(children)
dirs.push({
name,
path: dirPath,
type: 'directory',
children,
fileCount
})
}
}
// Compact single-child directories
for (let i = 0; i < dirs.length; i++) {
const dir = dirs[i]
while (
dir.children &&
dir.children.length === 1 &&
dir.children[0].type === 'directory'
) {
const child = dir.children[0]
dir.name = `${dir.name}/${child.name}`
dir.path = child.path
dir.children = child.children
dir.fileCount = child.fileCount
}
}
return [...dirs, ...fileNodes]
}
return toNodes(root, '')
}
// Build tree from files prop (top-level), or use nodes prop (recursive)
const displayNodes = computed(() => {
if (props.nodes) return props.nodes
if (props.files) return buildTree(props.files)
return []
})
// Auto-expand all dirs when files change (top-level only)
watch(() => props.files, (files) => {
if (!files || props.depth) return
expandedDirs.value = new Set()
expandAllNodes(displayNodes.value)
}, { immediate: true })
function expandAllNodes(nodes: StatusNode[]) {
for (const node of nodes) {
if (node.type === 'directory') {
expandedDirs.value.add(node.path)
if (node.children) expandAllNodes(node.children)
}
}
}
function toggleDir(path: string) {
if (expandedDirs.value.has(path)) {
expandedDirs.value.delete(path)
} else {
expandedDirs.value.add(path)
}
expandedDirs.value = new Set(expandedDirs.value)
}
function isExpanded(path: string): boolean {
return expandedDirs.value.has(path)
}
function handleClick(node: StatusNode) {
if (node.type === 'directory') {
toggleDir(node.path)
} else {
emit('select', node.path)
}
}
function getFileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || ''
const icons: Record<string, string> = {
ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx',
vue: 'vue', json: 'json', md: 'md',
css: 'css', scss: 'scss', html: 'html',
svg: 'svg', png: 'img', jpg: 'img', jpeg: 'img',
gif: 'img', ico: 'img', gitignore: 'git',
env: 'env', yml: 'yml', yaml: 'yml',
toml: 'toml', lock: 'lock'
}
return icons[ext] || 'file'
}
function badgeLabel(status: string): string {
if (status === 'untracked') return '?'
return (status[0] || '?').toUpperCase()
}
</script>
<template>
<div class="status-tree">
<div v-for="node in displayNodes" :key="node.path" class="tree-node">
<!-- Directory row -->
<div
v-if="node.type === 'directory'"
class="node-row dir-row"
@click="handleClick(node)"
>
<span class="expand-icon">
<svg
:class="{ expanded: isExpanded(node.path) }"
width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
>
<path d="M8 5l8 7-8 7z" />
</svg>
</span>
<span class="folder-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path v-if="isExpanded(node.path)" d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2z" />
<path v-else d="M3 6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
</svg>
</span>
<span class="node-name">{{ node.name }}</span>
<span class="dir-count">{{ node.fileCount }}</span>
</div>
<!-- File row -->
<div
v-else
:class="['node-row', 'file-row', { selected: selectedPath === node.path }]"
@click="handleClick(node)"
>
<span class="expand-icon spacer"></span>
<span :class="['status-badge', node.status]">{{ badgeLabel(node.status || '') }}</span>
<span :class="['file-icon', getFileIcon(node.name)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
<span class="node-name">{{ node.name }}</span>
</div>
<!-- Recursive children -->
<div v-if="node.type === 'directory' && isExpanded(node.path) && node.children" class="children">
<StatusTree
:nodes="node.children"
:selected-path="selectedPath"
:depth="(depth || 0) + 1"
@select="emit('select', $event)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.status-tree {
font-size: 13px;
user-select: none;
}
.node-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.node-row:hover {
background: var(--bg-hover);
}
.node-row.selected {
background: rgba(99, 102, 241, 0.15);
}
.expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
}
.expand-icon.spacer {
visibility: hidden;
}
.expand-icon svg {
transition: transform 0.15s;
}
.expand-icon svg.expanded {
transform: rotate(90deg);
}
.folder-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #f59e0b;
}
.file-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
}
.file-icon.ts, .file-icon.tsx { color: #3178c6; }
.file-icon.js, .file-icon.jsx { color: #f7df1e; }
.file-icon.vue { color: #42b883; }
.file-icon.json { color: #f5a623; }
.file-icon.md { color: #519aba; }
.file-icon.css, .file-icon.scss { color: #563d7c; }
.file-icon.html { color: #e34c26; }
.file-icon.svg, .file-icon.img { color: #a855f7; }
.file-icon.git { color: #f05032; }
.file-icon.env { color: #ecd53f; }
.file-icon.yml { color: #cb171e; }
.file-icon.lock { color: #6b7280; }
.node-name {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-count {
margin-left: auto;
background: var(--bg-hover);
color: var(--text-muted);
padding: 0.05rem 0.35rem;
border-radius: 8px;
font-size: 11px;
flex-shrink: 0;
}
.status-badge {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.status-badge.added {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-badge.modified {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.status-badge.deleted {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.status-badge.renamed {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.status-badge.untracked {
background: rgba(107, 114, 128, 0.2);
color: #6b7280;
}
.children {
padding-left: 1rem;
}
</style>

View File

@@ -0,0 +1,7 @@
export { default as DiffViewer } from './DiffViewer.vue'
export { default as FileTree } from './FileTree.vue'
export { default as CommitList } from './CommitList.vue'
export { default as BranchSelector } from './BranchSelector.vue'
export { default as ProjectTree } from './ProjectTree.vue'
export { default as FileViewer } from './FileViewer.vue'
export { default as StatusTree } from './StatusTree.vue'

View File

@@ -0,0 +1,398 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { TerminalSlot } from '@/types/transcript-debug'
import { useSessionState, type AgentStatus } from '@/stores/session-state'
const props = defineProps<{
agent: string
connected: boolean
terminals: TerminalSlot[]
activeSessionId: string | null
model?: string
version?: string
}>()
const sessionStore = useSessionState()
const STATUS_COLORS: Record<AgentStatus, string> = {
idle: '#6b7280',
thinking: '#60a5fa',
reading: '#22d3ee',
writing: '#4ade80',
toolUse: '#fbbf24',
permissionRequest: '#fb923c',
interrupted: '#f87171',
error: '#f87171',
sessionStart: '#60a5fa',
sessionEnd: '#6b7280',
}
// Derive PTY ID from the active terminal slot
const activePtyId = computed(() => {
if (!props.activeSessionId) return null
const t = props.terminals.find(t => t.sessionId === props.activeSessionId)
return t?.ephemeralSessionId ?? null
})
const agentStatusColor = computed(() => {
const ptyId = activePtyId.value
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
if (!state) return null
return STATUS_COLORS[state.status] || '#6b7280'
})
const agentStatusClass = computed(() => {
const ptyId = activePtyId.value
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
return state?.status || 'idle'
})
const activeIndex = computed(() => {
if (!props.activeSessionId) return -1
return props.terminals.findIndex(t => t.sessionId === props.activeSessionId)
})
const activeSlot = computed(() =>
activeIndex.value >= 0 ? props.terminals[activeIndex.value] : null
)
const emit = defineEmits<{
'switch-terminal': [sessionId: string]
'close-terminal': [sessionId: string]
}>()
const isOpen = ref(false)
const wrapperRef = ref<HTMLElement | null>(null)
function toggle(e: Event) {
e.stopPropagation()
isOpen.value = !isOpen.value
}
function onClickOutside(e: MouseEvent) {
if (!isOpen.value) return
if (wrapperRef.value && !wrapperRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
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>
<template>
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
<span v-if="agentStatusColor" class="state-dot status-dot" :class="agentStatusClass" :style="{ background: agentStatusColor }" />
<span v-else-if="activeSlot" class="state-dot badge-dot" :style="{ background: slotColor(activeSlot) }" />
<span class="agent-label">{{ agent }}</span>
<span v-if="terminals.length" class="term-count">{{ activeIndex >= 0 ? `${activeIndex + 1}/${terminals.length}` : 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"/>
<rect x="4" y="2" width="2" height="2" fill="currentColor"/>
</svg>
<Transition name="dropdown">
<div v-if="isOpen" class="dropdown">
<div v-if="model || version" class="dropdown-meta">
<span v-if="model" class="meta-model">{{ model }}</span>
<span v-if="version" class="meta-version">v{{ version }}</span>
</div>
<div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
<div
v-for="(t, idx) in terminals"
:key="t.sessionId"
class="dropdown-item terminal-item"
:class="{ active: t.sessionId === activeSessionId }"
@click.stop="handleSwitch(t.sessionId)"
>
<span class="shortcut-key">{{ idx + 1 }}</span>
<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>
</template>
<style scoped>
.agent-badge-wrapper {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
user-select: none;
padding: 3px 7px;
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.12);
transition: background 0.15s, border-color 0.15s, box-shadow 0.2s;
}
.agent-badge-wrapper:hover {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.4);
}
.agent-badge-wrapper.connected {
border: 2px solid;
border-image: conic-gradient(
from var(--border-angle, 0deg),
rgba(34, 211, 238, 1),
rgba(99, 102, 241, 0.7),
rgba(34, 211, 238, 0.15),
rgba(99, 102, 241, 0.7),
rgba(34, 211, 238, 1)
) 1;
background: rgba(34, 197, 94, 0.08);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
animation: border-spin 3s linear infinite;
}
.agent-badge-wrapper.connected:hover {
background: rgba(34, 197, 94, 0.15);
}
@property --border-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes border-spin {
to { --border-angle: 360deg; }
}
.agent-label {
font-size: 10px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: #a5b4fc;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.connected .agent-label {
color: #86efac;
}
.badge-dot,
.status-dot {
width: 5px;
height: 5px;
flex-shrink: 0;
}
.status-dot.thinking {
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.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;
}
.connected .caret {
color: rgba(134, 239, 172, 0.5);
}
.caret.open {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
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: 3px 0;
}
.dropdown-meta {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 3px;
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
margin-bottom: 2px;
}
.meta-model {
font-size: 8px;
font-family: 'Courier New', monospace;
color: rgba(165, 180, 252, 0.7);
background: rgba(99, 102, 241, 0.12);
padding: 0 4px;
line-height: 14px;
}
.meta-version {
font-size: 8px;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.3);
}
.dropdown-item {
padding: 5px 10px;
font-size: 9px;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
}
.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);
}
.shortcut-key {
font-size: 8px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.2);
min-width: 10px;
text-align: center;
flex-shrink: 0;
}
.dropdown-item.terminal-item.active .shortcut-key {
color: rgba(165, 180, 252, 0.7);
}
.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 */
.dropdown-enter-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-leave-active {
transition: opacity 0.1s ease, transform 0.1s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
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

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, TransitionGroup } from 'vue'
import type { ParsedAssistantMessage } from '@/types/transcript-debug'
import ThinkingBlock from './ThinkingBlock.vue'
import ToolCallBlock from './ToolCallBlock.vue'
import MarkdownContent from './MarkdownContent.vue'
import AskUserQuestionCard from './toolCards/AskUserQuestionCard.vue'
import ExitPlanModeCard from './toolCards/ExitPlanModeCard.vue'
import EnterPlanModeCard from './toolCards/EnterPlanModeCard.vue'
import ReadCard from './toolCards/ReadCard.vue'
import WriteCard from './toolCards/WriteCard.vue'
import BashCard from './toolCards/BashCard.vue'
import EditCard from './toolCards/EditCard.vue'
import GrepCard from './toolCards/GrepCard.vue'
import GlobCard from './toolCards/GlobCard.vue'
import TaskCard from './toolCards/TaskCard.vue'
const TASK_TOOLS = new Set(['Task', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
const props = defineProps<{
message: ParsedAssistantMessage
showHeader?: boolean
}>()
// Filter out empty text blocks (streaming placeholders)
const visibleTextBlocks = computed(() =>
props.message.textBlocks.filter(t => t.trim().length > 0)
)
// Show thinking animation when assistant has no real content yet
const isStreaming = computed(() =>
visibleTextBlocks.value.length === 0 &&
props.message.thinkingBlocks.length === 0 &&
props.message.toolCalls.length === 0 &&
!props.message.stopReason
)
function formatTime(ts: string): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
}
function formatTokens(n?: number): string {
if (!n) return '0'
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
</script>
<template>
<div :class="['assistant-bubble', { continuation: showHeader === false }]">
<div v-if="showHeader !== false" class="bubble-header">
<span class="role-badge">Assistant</span>
<span class="model-badge">{{ message.model }}</span>
<span v-if="message.usage" class="token-info">
{{ formatTokens(message.usage.input_tokens) }}in / {{ formatTokens(message.usage.output_tokens) }}out
</span>
<span v-if="message.stopReason" class="stop-reason">{{ message.stopReason }}</span>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<!-- All dynamic content in one TransitionGroup (fragment mode, no wrapper) -->
<TransitionGroup name="inner">
<!-- Streaming/thinking animation -->
<div v-if="isStreaming" key="streaming" class="thinking-animation">
<div class="thinking-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<span class="thinking-label">Thinking...</span>
</div>
<!-- Thinking blocks -->
<ThinkingBlock
v-for="(t, i) in message.thinkingBlocks"
:key="'think-' + i"
:content="t"
/>
<!-- Text content (filtered, markdown-rendered) -->
<MarkdownContent
v-for="(text, i) in visibleTextBlocks"
:key="'text-' + i"
:content="text"
/>
<!-- Tool calls (wrapped in div for TransitionGroup keying) -->
<div v-for="tc in message.toolCalls" :key="tc.id">
<AskUserQuestionCard v-if="tc.name === 'AskUserQuestion'" :call="tc" />
<ExitPlanModeCard v-else-if="tc.name === 'ExitPlanMode'" :call="tc" />
<EnterPlanModeCard v-else-if="tc.name === 'EnterPlanMode'" :call="tc" />
<ReadCard v-else-if="tc.name === 'Read'" :call="tc" />
<WriteCard v-else-if="tc.name === 'Write'" :call="tc" />
<BashCard v-else-if="tc.name === 'Bash'" :call="tc" />
<EditCard v-else-if="tc.name === 'Edit'" :call="tc" />
<GrepCard v-else-if="tc.name === 'Grep'" :call="tc" />
<GlobCard v-else-if="tc.name === 'Glob'" :call="tc" />
<TaskCard v-else-if="TASK_TOOLS.has(tc.name)" :call="tc" />
<ToolCallBlock v-else :call="tc" />
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.assistant-bubble {
background: transparent;
border: none;
border-radius: 0;
padding: 0.5rem 0.25rem;
}
.assistant-bubble.continuation {
padding-top: 0;
}
.bubble-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.role-badge {
font-size: 11px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.model-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.token-info {
font-size: 10px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.stop-reason {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--bg-hover);
color: var(--text-muted);
}
.timestamp {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Thinking animation */
.thinking-animation {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
}
.thinking-dots {
display: flex;
gap: 4px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
opacity: 0.4;
animation: pulse-dot 1.4s ease-in-out infinite;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse-dot {
0%, 80%, 100% {
opacity: 0.2;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1.1);
}
}
.thinking-label {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
.text-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
margin: 0.25rem 0;
}
</style>
<!-- Non-scoped: TransitionGroup classes must reach child component roots -->
<style>
.inner-enter-active {
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.inner-enter-from {
opacity: 0;
transform: translateY(6px);
}
.inner-leave-active {
transition: opacity 0.12s ease;
}
.inner-leave-to {
opacity: 0;
}
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { highlightCode } from '@/utils/markdown'
const props = withDefaults(defineProps<{
code: string
lang?: string
maxHeight?: string
}>(), {
lang: '',
maxHeight: '250px',
})
const highlighted = computed(() =>
highlightCode(props.code, props.lang || undefined)
)
const copyState = ref<'idle' | 'copied'>('idle')
function copy() {
navigator.clipboard.writeText(props.code).then(() => {
copyState.value = 'copied'
setTimeout(() => { copyState.value = 'idle' }, 1500)
})
}
</script>
<template>
<div class="code-block">
<button class="copy-btn" @click.stop="copy">
{{ copyState === 'copied' ? 'Copied!' : 'Copy' }}
</button>
<pre class="code-pre" :style="{ maxHeight }" v-html="highlighted"></pre>
</div>
</template>
<style scoped>
.code-block {
position: relative;
border-top: 1px solid rgba(255, 255, 255, 0.04);
background: var(--bg-secondary, #1a1a2e);
border-radius: 0 0 6px 6px;
}
.code-pre {
margin: 0;
padding: 0.35rem 0.6rem;
font-size: 11px;
line-height: 1.2;
color: var(--text-secondary);
white-space: pre;
word-break: normal;
overflow: auto;
background: transparent;
font-family: 'Consolas', 'Lucida Console', 'SF Mono', 'Fira Code', monospace;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
tab-size: 4;
}
.copy-btn {
position: absolute;
top: 2px;
right: 4px;
font-size: 10px;
padding: 0.15em 0.4em;
color: var(--text-muted, #94a3b8);
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
z-index: 1;
}
.code-block:hover .copy-btn { opacity: 1; }
.copy-btn:hover { color: var(--text-primary, #e2e8f0); background: rgba(255, 255, 255, 0.1); }
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import type { ParsedSystemMessage } from '@/types/transcript-debug'
defineProps<{
message: ParsedSystemMessage
}>()
</script>
<template>
<div class="compact-boundary">
<div class="compact-line">
<!-- Left compression wave -->
<svg class="wave wave-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
<!-- Base void layer -->
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
<!-- Compression chevrons pushing right -->
<rect x="4" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
<rect x="5" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
<rect x="6" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
<rect x="14" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
<rect x="15" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
<rect x="16" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
<rect x="26" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
<rect x="27" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="28" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
<rect x="40" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="41" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
<rect x="42" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
<rect x="56" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
<rect x="57" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="58" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
<rect x="74" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="75" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
<rect x="76" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
<rect x="94" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
<rect x="95" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="96" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
<rect x="116" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="117" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
<rect x="118" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
<rect x="140" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
<rect x="141" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="142" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
<rect x="160" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="161" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
<rect x="162" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="178" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
<rect x="179" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="180" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
<!-- Data particles being pulled inward -->
<rect x="8" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
<rect x="20" y="5" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
<rect x="34" y="8" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
<rect x="48" y="4" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
<rect x="52" y="9" width="2" height="1" fill="#fde68a" opacity="0.3"/>
<rect x="66" y="5" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="68" y="8" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
<rect x="84" y="4" width="1" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="86" y="9" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="104" y="6" width="2" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="108" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
<rect x="128" y="5" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
<rect x="132" y="8" width="1" height="1" fill="#fde68a" opacity="0.5"/>
<rect x="150" y="4" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
<rect x="154" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
<rect x="170" y="6" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="186" y="5" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="192" y="7" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
</svg>
<!-- Center badge -->
<span class="compact-badge">Compactado</span>
<!-- Right compression wave -->
<svg class="wave wave-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
<!-- Base void layer -->
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
<!-- Compression chevrons pushing left (mirrored) -->
<rect x="195" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
<rect x="194" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
<rect x="193" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
<rect x="185" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
<rect x="184" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
<rect x="183" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
<rect x="173" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
<rect x="172" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="171" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
<rect x="159" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="158" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
<rect x="157" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
<rect x="143" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
<rect x="142" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="141" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
<rect x="125" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="124" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
<rect x="123" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
<rect x="105" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
<rect x="104" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="103" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
<rect x="83" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="82" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
<rect x="81" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
<rect x="59" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
<rect x="58" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="57" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
<rect x="39" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="38" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
<rect x="37" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="21" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
<rect x="20" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="19" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
<!-- Data particles being pulled inward -->
<rect x="190" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
<rect x="178" y="8" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
<rect x="164" y="5" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
<rect x="151" y="9" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
<rect x="146" y="4" width="2" height="1" fill="#fde68a" opacity="0.3"/>
<rect x="133" y="8" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="130" y="5" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
<rect x="115" y="9" width="1" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="112" y="4" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="94" y="7" width="2" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="90" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
<rect x="71" y="8" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
<rect x="67" y="5" width="1" height="1" fill="#fde68a" opacity="0.5"/>
<rect x="49" y="9" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
<rect x="45" y="4" width="1" height="1" fill="#fde68a" opacity="0.55"/>
<rect x="29" y="7" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="13" y="8" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="7" y="6" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
</svg>
</div>
</div>
</template>
<style scoped>
.compact-boundary {
padding: 0.25rem 0;
user-select: none;
}
.compact-line {
display: flex;
align-items: center;
gap: 0;
height: 28px;
}
.wave {
flex: 1;
height: 100%;
image-rendering: pixelated;
overflow: hidden;
}
.compact-badge {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(245, 158, 11, 0.85);
padding: 0 6px;
letter-spacing: 1px;
text-transform: uppercase;
z-index: 1;
animation: compress-pulse 3s ease-in-out infinite;
}
@keyframes compress-pulse {
0%, 100% {
text-shadow: 0 0 4px rgba(245, 158, 11, 0.2);
color: rgba(245, 158, 11, 0.7);
}
50% {
text-shadow: 0 0 10px rgba(245, 158, 11, 0.5), 0 0 3px rgba(251, 191, 36, 0.3);
color: rgba(245, 158, 11, 0.95);
}
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { parseMarkdown, ensureStyles } from '@/utils/markdown'
const props = defineProps<{
content: string
}>()
const html = computed(() => parseMarkdown(props.content))
onMounted(ensureStyles)
</script>
<template>
<div class="md-content" v-html="html"></div>
</template>
<style scoped>
.md-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,642 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { AgentName, SessionInfo } from '@/types/transcript-debug'
const props = defineProps<{
visible: boolean
agents: { id: AgentName; label: string }[]
currentAgent: AgentName
error?: string | null
}>()
const emit = defineEmits<{
close: []
'create-new': [agent: AgentName, initialPrompt: string]
resume: [sessionId: string, agent: AgentName]
}>()
type Tab = 'new' | 'resume'
const activeTab = ref<Tab>('new')
const selectedAgent = ref<AgentName>(props.currentAgent)
const sessionsMap = ref<Record<AgentName, SessionInfo[]>>({} as any)
const loadingSessions = ref(false)
const resumeFilter = ref<AgentName | 'all'>('all')
const initialPrompt = ref('')
const filteredAgents = computed(() => {
if (resumeFilter.value === 'all') return props.agents
return props.agents.filter(a => a.id === resumeFilter.value)
})
const hasAnySessions = computed(() =>
props.agents.some(a => (sessionsMap.value[a.id]?.length ?? 0) > 0)
)
// Reset state when modal opens
watch(() => props.visible, async (open) => {
if (open) {
// If reopening after a resume error, show the resume tab
activeTab.value = props.error ? 'resume' : 'new'
selectedAgent.value = props.currentAgent
resumeFilter.value = 'all'
initialPrompt.value = ''
await fetchAllSessions()
}
})
async function fetchAllSessions() {
loadingSessions.value = true
const map: Record<string, SessionInfo[]> = {}
await Promise.all(
props.agents.map(async (a) => {
try {
const res = await apiFetch(`/api/transcript-debug/sessions?agent=${a.id}`)
if (res.ok) map[a.id] = await res.json()
else map[a.id] = []
} catch {
map[a.id] = []
}
})
)
sessionsMap.value = map as Record<AgentName, SessionInfo[]>
loadingSessions.value = false
}
function truncateMessage(msg: string, max = 60): string {
if (!msg) return ''
return msg.length > max ? msg.slice(0, max) + '...' : msg
}
function formatDate(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = now.getTime() - d.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return d.toLocaleDateString()
}
function handleStart() {
emit('create-new', selectedAgent.value, initialPrompt.value)
}
function handleResume(sessionId: string, agent: AgentName) {
emit('resume', sessionId, agent)
}
</script>
<template>
<Teleport to="body">
<Transition name="nsm">
<div v-if="visible" class="nsm-backdrop" @click.self="emit('close')">
<div class="nsm-panel">
<!-- Header -->
<div class="nsm-header">
<span class="nsm-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
New Session
</span>
<button class="nsm-close" @click="emit('close')" title="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Tabs -->
<div class="nsm-tabs">
<button
:class="['nsm-tab', { active: activeTab === 'new' }]"
@click="activeTab = 'new'"
>
New session
</button>
<button
:class="['nsm-tab', { active: activeTab === 'resume' }]"
@click="activeTab = 'resume'"
>
Resume existing
</button>
</div>
<!-- Error banner -->
<div v-if="error" class="nsm-error">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
{{ error }}
</div>
<!-- Body -->
<div class="nsm-body">
<!-- Tab: New session -->
<div v-if="activeTab === 'new'" class="nsm-new">
<label class="nsm-label">Agent</label>
<div class="nsm-agent-grid">
<button
v-for="a in agents"
:key="a.id"
:class="['nsm-agent-btn', { active: selectedAgent === a.id }]"
@click="selectedAgent = a.id"
>
{{ a.label }}
</button>
</div>
<label class="nsm-label">Initial prompt <span class="nsm-optional">(optional)</span></label>
<textarea
v-model="initialPrompt"
class="nsm-prompt-input"
placeholder="What should the agent do first?"
rows="3"
@keydown.ctrl.enter="handleStart"
@keydown.meta.enter="handleStart"
/>
<button class="nsm-start-btn" @click="handleStart">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Start
</button>
</div>
<!-- Tab: Resume existing -->
<div v-if="activeTab === 'resume'" class="nsm-resume">
<!-- Agent filter -->
<div class="nsm-filter">
<button
:class="['nsm-filter-btn', { active: resumeFilter === 'all' }]"
@click="resumeFilter = 'all'"
>
All
</button>
<button
v-for="a in agents"
:key="a.id"
:class="['nsm-filter-btn', { active: resumeFilter === a.id }]"
@click="resumeFilter = a.id"
>
{{ a.label }}
<span v-if="sessionsMap[a.id]?.length" class="nsm-filter-count">{{ sessionsMap[a.id].length }}</span>
</button>
</div>
<div v-if="loadingSessions" class="nsm-loading">
Loading sessions...
</div>
<template v-else>
<div
v-for="a in filteredAgents"
:key="a.id"
class="nsm-agent-group"
>
<template v-if="sessionsMap[a.id]?.length">
<div v-if="resumeFilter === 'all'" class="nsm-group-header">
<span class="nsm-group-agent">{{ a.label }}</span>
<span class="nsm-group-count">{{ sessionsMap[a.id].length }}</span>
</div>
<div
v-for="s in sessionsMap[a.id]"
:key="s.id"
class="nsm-session-row"
@click="handleResume(s.id, a.id)"
>
<div class="nsm-session-info">
<span class="nsm-session-msg">
{{ truncateMessage(s.firstUserMessage) || s.id.slice(0, 12) + '...' }}
</span>
<span class="nsm-session-meta">
{{ formatDate(s.mtimeISO) }}
</span>
</div>
<button class="nsm-open-btn" @click.stop="handleResume(s.id, a.id)">
Open
</button>
</div>
</template>
</div>
<div v-if="!hasAnySessions" class="nsm-empty">
No existing sessions found
</div>
<div
v-else-if="filteredAgents.every(a => !sessionsMap[a.id]?.length)"
class="nsm-empty"
>
No sessions for this agent
</div>
</template>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.nsm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 10013;
display: flex;
align-items: center;
justify-content: center;
}
.nsm-panel {
width: 90%;
max-width: 480px;
max-height: 70vh;
background: var(--bg-primary, #1a1a2e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.nsm-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary, #16162a);
border-bottom: 1px solid var(--border-color, #333);
flex-shrink: 0;
}
.nsm-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.nsm-title svg {
color: #6366f1;
}
.nsm-close {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--text-muted, #888);
cursor: pointer;
transition: all 0.15s;
}
.nsm-close:hover {
background: var(--bg-hover, #2a2a4a);
color: var(--text-primary, #e0e0e0);
}
/* Tabs */
.nsm-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #333);
flex-shrink: 0;
}
.nsm-tab {
flex: 1;
padding: 0.6rem 1rem;
border: none;
background: transparent;
color: var(--text-muted, #888);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
border-bottom: 2px solid transparent;
}
.nsm-tab:hover {
color: var(--text-primary, #e0e0e0);
background: var(--bg-hover, #2a2a4a);
}
.nsm-tab.active {
color: #6366f1;
border-bottom-color: #6366f1;
}
/* Error banner */
.nsm-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.12);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
font-size: 12px;
flex-shrink: 0;
}
.nsm-error svg {
flex-shrink: 0;
color: #ef4444;
}
/* Body */
.nsm-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* New session tab */
.nsm-new {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.nsm-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.nsm-agent-grid {
display: flex;
gap: 0.5rem;
}
.nsm-agent-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #333);
background: var(--bg-secondary, #16162a);
color: var(--text-muted, #888);
font-size: 12px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.nsm-agent-btn:hover {
border-color: #6366f1;
color: var(--text-primary, #e0e0e0);
}
.nsm-agent-btn.active {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
font-weight: 600;
}
.nsm-optional {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
color: var(--text-muted, #666);
font-size: 10px;
}
.nsm-prompt-input {
width: 100%;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border-color, #333);
background: var(--bg-secondary, #16162a);
color: var(--text-primary, #e0e0e0);
font-size: 12px;
font-family: 'Courier New', monospace;
border-radius: 6px;
resize: vertical;
min-height: 2.5rem;
max-height: 8rem;
transition: border-color 0.15s;
}
.nsm-prompt-input::placeholder {
color: var(--text-muted, #666);
}
.nsm-prompt-input:focus {
outline: none;
border-color: #6366f1;
}
.nsm-start-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.6rem 1rem;
border: none;
background: #6366f1;
color: white;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
margin-top: 0.5rem;
}
.nsm-start-btn:hover {
background: #5558e6;
}
/* Resume tab */
.nsm-resume {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.nsm-filter {
display: flex;
gap: 0.35rem;
}
.nsm-filter-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border-color, #333);
background: transparent;
color: var(--text-muted, #888);
font-size: 11px;
font-weight: 500;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.nsm-filter-btn:hover {
border-color: #6366f1;
color: var(--text-primary, #e0e0e0);
}
.nsm-filter-btn.active {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
font-weight: 600;
}
.nsm-filter-count {
font-size: 9px;
background: rgba(255, 255, 255, 0.08);
padding: 0 0.3rem;
border-radius: 6px;
line-height: 1.4;
}
.nsm-filter-btn.active .nsm-filter-count {
background: rgba(99, 102, 241, 0.25);
}
.nsm-loading {
text-align: center;
color: var(--text-muted, #888);
font-size: 12px;
padding: 1rem;
}
.nsm-agent-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.nsm-group-header {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0;
}
.nsm-group-agent {
font-size: 11px;
font-weight: 600;
color: var(--accent, #6366f1);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.nsm-group-count {
font-size: 10px;
color: var(--text-muted, #888);
background: var(--bg-secondary, #16162a);
padding: 0.1rem 0.4rem;
border-radius: 8px;
}
.nsm-session-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.nsm-session-row:hover {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.05);
}
.nsm-session-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.nsm-session-msg {
font-size: 12px;
color: var(--text-primary, #e0e0e0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nsm-session-meta {
font-size: 10px;
color: var(--text-muted, #888);
}
.nsm-open-btn {
flex-shrink: 0;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border-color, #333);
background: transparent;
color: var(--text-muted, #888);
font-size: 11px;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.nsm-open-btn:hover {
border-color: #6366f1;
color: #818cf8;
}
.nsm-empty {
text-align: center;
color: var(--text-muted, #888);
font-size: 12px;
padding: 2rem 1rem;
}
/* Transitions */
.nsm-enter-active,
.nsm-leave-active {
transition: opacity 0.2s ease;
}
.nsm-enter-active .nsm-panel,
.nsm-leave-active .nsm-panel {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.nsm-enter-from,
.nsm-leave-to {
opacity: 0;
}
.nsm-enter-from .nsm-panel,
.nsm-leave-to .nsm-panel {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,558 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { HooksApprovalPermissionRequest } from '@/types/hooks-approval'
import MarkdownContent from './MarkdownContent.vue'
import { ensureStyles } from '@/utils/markdown'
const props = defineProps<{
request: HooksApprovalPermissionRequest
}>()
const emit = defineEmits<{
respond: [requestId: string, decision: string, reason?: string]
ignore: [requestId: string]
}>()
// ── Mode detection ──
type CardMode = 'permission' | 'plan' | 'question'
const mode = computed<CardMode>(() => {
if (props.request.tool_name === 'ExitPlanMode') return 'plan'
if (props.request.tool_name === 'AskUserQuestion') return 'question'
return 'permission'
})
const input = computed(() => {
if (!props.request.tool_input) return null
return props.request.tool_input as Record<string, unknown>
})
// ── Plan mode ──
const planText = computed(() => {
if (mode.value !== 'plan' || !input.value) return ''
return (input.value.plan as string) || ''
})
const showPlanEditor = ref(false)
const planEditText = ref('')
function handlePlanEdit() {
if (!showPlanEditor.value) {
showPlanEditor.value = true
return
}
emit('respond', props.request.requestId, 'deny', planEditText.value || 'Continue with modifications.')
}
// ── Question mode ──
interface QuestionOption {
label: string
description?: string
}
interface QuestionItem {
question: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
const questions = computed<QuestionItem[]>(() => {
if (mode.value !== 'question' || !input.value) return []
const qs = input.value.questions
if (Array.isArray(qs)) return qs as QuestionItem[]
return []
})
// Track selected options per question (keyed by question text)
const selectedAnswers = ref<Record<string, Set<string>>>({})
const customAnswers = ref<Record<string, string>>({})
const showCustom = ref<Record<string, boolean>>({})
function toggleOption(questionText: string, label: string, multiSelect?: boolean) {
if (!selectedAnswers.value[questionText]) {
selectedAnswers.value[questionText] = new Set()
}
const set = selectedAnswers.value[questionText]
if (multiSelect) {
if (set.has(label)) set.delete(label)
else set.add(label)
} else {
if (set.has(label)) set.clear()
else { set.clear(); set.add(label) }
}
// Trigger reactivity
selectedAnswers.value = { ...selectedAnswers.value }
}
function toggleCustom(questionText: string) {
showCustom.value = { ...showCustom.value, [questionText]: !showCustom.value[questionText] }
}
function submitAnswers() {
// Build answers object: { "question text": "selected label" }
const answers: Record<string, string> = {}
for (const q of questions.value) {
const selected = selectedAnswers.value[q.question]
const custom = customAnswers.value[q.question]
if (custom?.trim()) {
answers[q.question] = custom.trim()
} else if (selected?.size) {
answers[q.question] = Array.from(selected).join(', ')
}
}
// Deny the tool (so Claude gets the answer as the deny message)
// The answer goes as a structured JSON string
const answerText = JSON.stringify({ answers })
emit('respond', props.request.requestId, 'deny', answerText)
}
function allowQuestion() {
// Let AskUserQuestion run normally (shows in terminal)
emit('respond', props.request.requestId, 'allow')
}
// ── Permission mode ──
const showFreeResponse = ref(false)
const freeText = ref('')
function handleFreeResponse() {
if (!showFreeResponse.value) {
showFreeResponse.value = true
return
}
if (freeText.value.trim()) {
emit('respond', props.request.requestId, 'deny', freeText.value.trim())
}
}
onMounted(ensureStyles)
// ── Common ──
function formatInput(inp: unknown): string {
if (!inp) return ''
if (typeof inp === 'string') return inp
try {
return JSON.stringify(inp, null, 2)
} catch {
return String(inp)
}
}
function elapsed(): string {
const ms = Date.now() - props.request.timestamp
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
return `${Math.floor(s / 60)}m ${s % 60}s ago`
}
</script>
<template>
<!-- PLAN MODE (ExitPlanMode) -->
<div v-if="mode === 'plan'" class="card plan-card">
<div class="card-header plan-header">
<span class="card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</span>
<span class="card-label">Plan Approval</span>
<span class="card-elapsed">{{ elapsed() }}</span>
</div>
<div class="card-body">
<div v-if="planText" class="plan-text">
<MarkdownContent :content="planText" />
</div>
<div v-else class="plan-empty">Claude is waiting for plan approval</div>
<Transition name="expand">
<div v-if="showPlanEditor" class="edit-section">
<textarea v-model="planEditText" class="edit-textarea" placeholder="Add instructions or feedback..." rows="3"></textarea>
</div>
</Transition>
</div>
<div class="card-actions">
<button class="btn btn-approve" @click="emit('respond', request.requestId, 'allow')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Approve
</button>
<button class="btn btn-edit" @click="handlePlanEdit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ showPlanEditor ? 'Send' : 'Edit & Continue' }}
</button>
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'deny')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Reject
</button>
<button class="btn btn-ignore" @click="emit('ignore', request.requestId)" title="Remove from UI without responding">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
Ignore
</button>
</div>
</div>
<!-- QUESTION MODE (AskUserQuestion) -->
<div v-else-if="mode === 'question'" class="card question-card">
<div class="card-header question-header">
<span class="card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</span>
<span class="card-label">Question</span>
<span class="card-elapsed">{{ elapsed() }}</span>
</div>
<div class="card-body">
<div v-for="(q, qi) in questions" :key="qi" class="question-block">
<div class="question-text">
<span v-if="q.header" class="question-tag">{{ q.header }}</span>
{{ q.question }}
<span v-if="q.multiSelect" class="multi-hint">(multiple)</span>
</div>
<div v-if="q.options?.length" class="options-grid">
<button
v-for="opt in q.options"
:key="opt.label"
:class="['option-btn', { selected: selectedAnswers[q.question]?.has(opt.label) }]"
@click="toggleOption(q.question, opt.label, q.multiSelect)"
>
<span class="option-label">{{ opt.label }}</span>
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
</button>
<button :class="['option-btn other', { selected: showCustom[q.question] }]" @click="toggleCustom(q.question)">
<span class="option-label">Other</span>
</button>
</div>
<Transition name="expand">
<div v-if="showCustom[q.question] || !q.options?.length" class="custom-input">
<textarea
v-model="customAnswers[q.question]"
class="edit-textarea"
:placeholder="q.options?.length ? 'Custom answer...' : 'Type your answer...'"
rows="2"
></textarea>
</div>
</Transition>
</div>
</div>
<div class="card-actions">
<button class="btn btn-approve" @click="submitAnswers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
Answer
</button>
<button class="btn btn-allow-terminal" @click="allowQuestion">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
Ask in Terminal
</button>
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'deny')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Dismiss
</button>
<button class="btn btn-ignore" @click="emit('ignore', request.requestId)" title="Remove from UI without responding">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
Ignore
</button>
</div>
</div>
<!-- PERMISSION MODE (generic) -->
<div v-else class="card permission-card">
<div class="card-header perm-header">
<span class="card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</span>
<span class="card-label">Permission Request</span>
<span class="card-elapsed">{{ elapsed() }}</span>
</div>
<div class="card-body">
<div class="info-row" v-if="request.tool_name">
<span class="info-label">Tool</span>
<code class="info-value tool-name">{{ request.tool_name }}</code>
</div>
<div class="info-row" v-if="request.agent_name">
<span class="info-label">Agent</span>
<span class="info-value">{{ request.agent_name }}</span>
</div>
<div v-if="request.tool_input" class="input-block">
<span class="info-label">Input</span>
<pre class="input-pre">{{ formatInput(request.tool_input) }}</pre>
</div>
<Transition name="expand">
<div v-if="showFreeResponse" class="edit-section">
<textarea v-model="freeText" class="edit-textarea" placeholder="Deny with a custom message..." rows="2"></textarea>
</div>
</Transition>
</div>
<div class="card-actions">
<button class="btn btn-allow" @click="emit('respond', request.requestId, 'allow')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Allow
</button>
<button class="btn btn-allow-always" @click="emit('respond', request.requestId, 'allowAlways')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 11 14 15 10"/>
</svg>
Always
</button>
<button class="btn btn-free" @click="handleFreeResponse">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ showFreeResponse ? 'Send' : 'Message' }}
</button>
<button class="btn btn-deny" @click="emit('respond', request.requestId, 'deny')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Deny
</button>
<button class="btn btn-ignore" @click="emit('ignore', request.requestId)" title="Remove from UI without responding">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
Ignore
</button>
</div>
</div>
</template>
<style scoped>
/* ── Shared card base ── */
.card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
animation: slideIn 0.2s ease-out;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.card-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-icon { display: flex; align-items: center; }
.card-elapsed {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.card-body {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.card-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
/* ── Permission (yellow) ── */
.permission-card { border-left: 3px solid #f59e0b; }
.perm-header { background: rgba(245, 158, 11, 0.06); }
.perm-header .card-icon, .perm-header .card-label { color: #f59e0b; }
/* ── Plan (purple) ── */
.plan-card { border-left: 3px solid #8b5cf6; }
.plan-header { background: rgba(139, 92, 246, 0.06); }
.plan-header .card-icon, .plan-header .card-label { color: #8b5cf6; }
/* ── Question (blue) ── */
.question-card { border-left: 3px solid #0ea5e9; }
.question-header { background: rgba(14, 165, 233, 0.06); }
.question-header .card-icon, .question-header .card-label { color: #0ea5e9; }
/* ── Info rows (permission) ── */
.info-row { display: flex; align-items: center; gap: 0.5rem; }
.info-label { font-size: 11px; color: var(--text-muted); font-weight: 500; min-width: 40px; flex-shrink: 0; }
.info-value { font-size: 13px; color: var(--text-primary); }
.tool-name {
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.input-block { display: flex; flex-direction: column; gap: 0.25rem; }
.input-pre {
margin: 0;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 150px;
overflow-y: auto;
}
/* ── Plan body ── */
.plan-text {
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.plan-empty { font-size: 13px; color: var(--text-muted); font-style: italic; padding: 0.5rem 0; }
/* ── Question body ── */
.question-block { display: flex; flex-direction: column; gap: 0.5rem; }
.question-block + .question-block { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border-color); }
.question-text { font-size: 13px; color: var(--text-primary); font-weight: 500; line-height: 1.4; }
.question-tag {
display: inline-block;
background: rgba(14, 165, 233, 0.12);
color: #0ea5e9;
font-size: 10px;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
margin-right: 0.3rem;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.multi-hint { font-size: 11px; color: var(--text-muted); font-weight: 400; }
.options-grid { display: flex; flex-direction: column; gap: 0.35rem; }
.option-btn {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.5rem 0.65rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: all 0.15s;
}
.option-btn:hover { border-color: #0ea5e9; background: rgba(14, 165, 233, 0.04); }
.option-btn.selected { border-color: #0ea5e9; background: rgba(14, 165, 233, 0.1); }
.option-btn.other { border-style: dashed; }
.option-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.option-desc { font-size: 11px; color: var(--text-muted); line-height: 1.3; }
.custom-input { margin-top: 0.25rem; }
/* ── Shared edit/textarea ── */
.edit-section { margin-top: 0.25rem; }
.edit-textarea {
width: 100%;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.edit-textarea:focus { border-color: var(--accent, #6366f1); }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.btn-allow { background: #22c55e; color: white; }
.btn-allow:hover { background: #16a34a; }
.btn-allow-always { background: #0ea5e9; color: white; }
.btn-allow-always:hover { background: #0284c7; }
.btn-allow-terminal { background: #64748b; color: white; }
.btn-allow-terminal:hover { background: #475569; }
.btn-approve { background: #22c55e; color: white; }
.btn-approve:hover { background: #16a34a; }
.btn-edit { background: #8b5cf6; color: white; }
.btn-edit:hover { background: #7c3aed; }
.btn-free { background: #8b5cf6; color: white; }
.btn-free:hover { background: #7c3aed; }
.btn-deny { background: #ef4444; color: white; }
.btn-deny:hover { background: #dc2626; }
.btn-reject { background: #ef4444; color: white; }
.btn-reject:hover { background: #dc2626; }
.btn-ignore { background: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.btn-ignore:hover { background: var(--bg-hover); color: var(--text-secondary); }
/* ── Transitions ── */
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
.expand-enter-from, .expand-leave-to { opacity: 0; max-height: 0; }
.expand-enter-to, .expand-leave-from { max-height: 200px; }
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { HooksApprovalPlanRequest } from '@/types/hooks-approval'
import MarkdownContent from './MarkdownContent.vue'
import { ensureStyles } from '@/utils/markdown'
const props = defineProps<{
request: HooksApprovalPlanRequest
}>()
const emit = defineEmits<{
respond: [requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string]
}>()
const showEditor = ref(false)
const editReason = ref('')
function elapsed(): string {
const ms = Date.now() - props.request.timestamp
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
return `${Math.floor(s / 60)}m ${s % 60}s ago`
}
onMounted(ensureStyles)
function handleEdit() {
if (!showEditor.value) {
showEditor.value = true
return
}
emit('respond', props.request.requestId, 'edit', editReason.value)
}
</script>
<template>
<div class="plan-card">
<div class="card-header">
<span class="card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</span>
<span class="card-label">Plan Approval</span>
<span class="card-elapsed">{{ elapsed() }}</span>
</div>
<div class="card-body">
<div v-if="request.lastAssistantText" class="plan-text">
<MarkdownContent :content="request.lastAssistantText" />
</div>
<div v-else class="plan-empty">
Claude is waiting for plan approval
</div>
<Transition name="expand">
<div v-if="showEditor" class="edit-section">
<textarea
v-model="editReason"
class="edit-textarea"
placeholder="Add instructions or feedback..."
rows="3"
></textarea>
</div>
</Transition>
</div>
<div class="card-actions">
<button class="btn btn-approve" @click="emit('respond', request.requestId, 'approve')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
Approve
</button>
<button class="btn btn-edit" @click="handleEdit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ showEditor ? 'Send' : 'Edit & Continue' }}
</button>
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'reject')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Reject
</button>
</div>
</div>
</template>
<style scoped>
.plan-card {
border: 1px solid var(--border-color);
border-left: 3px solid #8b5cf6;
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
animation: slideIn 0.2s ease-out;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(139, 92, 246, 0.06);
border-bottom: 1px solid var(--border-color);
}
.card-icon {
color: #8b5cf6;
display: flex;
align-items: center;
}
.card-label {
font-size: 12px;
font-weight: 600;
color: #8b5cf6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-elapsed {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.card-body {
padding: 0.6rem 0.75rem;
}
.plan-text {
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.plan-empty {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0;
}
.edit-section {
margin-top: 0.5rem;
}
.edit-textarea {
width: 100%;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.edit-textarea:focus {
border-color: #8b5cf6;
}
.card-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-color);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.btn-approve {
background: #22c55e;
color: white;
}
.btn-approve:hover {
background: #16a34a;
}
.btn-edit {
background: #8b5cf6;
color: white;
}
.btn-edit:hover {
background: #7c3aed;
}
.btn-reject {
background: #ef4444;
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
/* ── Expand transition ── */
.expand-enter-active,
.expand-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
}
.expand-enter-to,
.expand-leave-from {
max-height: 200px;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedProgressGroup, ParsedProgressEvent } from '@/types/transcript-debug'
const props = defineProps<{
group: ParsedProgressGroup
}>()
const expanded = ref(false)
const hookEvents = computed(() =>
props.group.events.filter(e => e.dataType === 'hook_progress')
)
const mcpEvents = computed(() =>
props.group.events.filter(e => e.dataType === 'mcp_progress')
)
function hookLabel(e: ParsedProgressEvent): string {
return e.hookEvent || e.hookName?.split(':')[0] || 'hook'
}
</script>
<template>
<div class="progress-group">
<button class="progress-toggle" @click="expanded = !expanded">
<svg
:class="['chevron', { rotated: expanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="toggle-pills">
<span v-for="he in hookEvents" :key="he.uuid" :class="['hook-pill', he.hookEvent?.toLowerCase()]">
{{ hookLabel(he) }}
</span>
<span v-for="me in mcpEvents" :key="me.uuid" :class="['mcp-pill', me.mcpStatus]">
MCP {{ me.mcpStatus }}
<span v-if="me.mcpElapsedMs != null" class="mcp-ms">{{ me.mcpElapsedMs }}ms</span>
</span>
<span v-if="!hookEvents.length && !mcpEvents.length" class="generic-pill">
{{ group.events.length }} progress events
</span>
</span>
</button>
<div v-if="expanded" class="progress-details">
<div v-for="e in group.events" :key="e.uuid" class="detail-row">
<!-- Hook progress -->
<template v-if="e.dataType === 'hook_progress'">
<span class="row-icon hook-color">&#9881;</span>
<span :class="['row-event', e.hookEvent?.toLowerCase()]">{{ e.hookEvent }}</span>
<span class="row-name">{{ e.hookName }}</span>
<span v-if="e.command" class="row-command" :title="e.command">
{{ e.command.length > 80 ? e.command.slice(0, 80) + '...' : e.command }}
</span>
</template>
<!-- MCP progress -->
<template v-else-if="e.dataType === 'mcp_progress'">
<span class="row-icon mcp-color">&#9889;</span>
<span :class="['row-status', e.mcpStatus]">{{ e.mcpStatus }}</span>
<span class="row-server">{{ e.mcpServerName }}</span>
<span class="row-tool">{{ e.mcpToolName }}</span>
<span v-if="e.mcpElapsedMs != null" class="row-elapsed">{{ e.mcpElapsedMs }}ms</span>
</template>
<!-- Generic -->
<template v-else>
<span class="row-icon">&#8226;</span>
<span class="row-generic">{{ e.dataType }}</span>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.progress-group {
border-radius: 6px;
overflow: hidden;
opacity: 0.7;
transition: opacity 0.15s;
}
.progress-group:hover {
opacity: 0.9;
}
.progress-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
text-align: left;
}
.progress-toggle:hover {
background: var(--bg-hover);
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
.toggle-pills {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
/* Hook pills */
.hook-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
}
.hook-pill.pretooluse {
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
}
.hook-pill.posttooluse {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
.hook-pill.sessionstart {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
/* MCP pills */
.mcp-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.mcp-pill.started {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
.mcp-pill.completed {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.mcp-ms {
opacity: 0.8;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.generic-pill {
font-size: 10px;
color: var(--text-muted);
}
/* Expanded details */
.progress-details {
padding: 0.25rem 0.5rem 0.4rem 1.5rem;
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 6px 6px;
background: var(--bg-primary);
}
.detail-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 10px;
color: var(--text-muted);
border-left: 2px solid var(--border-color);
padding-left: 0.5rem;
margin-left: 0.25rem;
}
.row-icon {
font-size: 10px;
flex-shrink: 0;
}
.hook-color { color: #fbbf24; }
.mcp-color { color: #38bdf8; }
.row-event {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.row-event.pretooluse { color: #fbbf24; }
.row-event.posttooluse { color: #a855f7; }
.row-event.sessionstart { color: #22c55e; }
.row-name {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.row-command {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
opacity: 0.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.row-status {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.row-status.started { color: #38bdf8; }
.row-status.completed { color: #22c55e; }
.row-server {
font-size: 9px;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background: rgba(56, 189, 248, 0.08);
color: #38bdf8;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
}
.row-tool {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-elapsed {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #22c55e;
white-space: nowrap;
margin-left: auto;
}
.row-generic {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
content: string
}>()
const selectedLine = ref<number | null>(null)
const lines = computed(() => {
if (!props.content) return []
return props.content.split('\n').filter(l => l.trim())
})
function selectLine(idx: number) {
selectedLine.value = selectedLine.value === idx ? null : idx
}
function formatJson(line: string): string {
try {
const obj = JSON.parse(line)
return JSON.stringify(obj, null, 2)
} catch {
return line
}
}
function getLinePreview(line: string): string {
try {
const obj = JSON.parse(line)
const type = obj.type || 'unknown'
if (type === 'assistant' && obj.message?.content) {
const first = obj.message.content[0]
const hint = first?.type || ''
return `assistant [${hint}]`
}
if (type === 'user') {
const c = obj.message?.content
const preview = typeof c === 'string' ? c.slice(0, 40) : '[blocks]'
return `user: ${preview}`
}
if (type === 'progress') {
return `progress: ${obj.data?.type || '?'}`
}
return type
} catch {
return line.slice(0, 40)
}
}
function typeClass(line: string): string {
try {
const obj = JSON.parse(line)
return `type-${obj.type || 'unknown'}`
} catch {
return 'type-unknown'
}
}
</script>
<template>
<div class="raw-viewer">
<div class="viewer-header">
<span class="viewer-title">Raw JSONL</span>
<span class="line-count">{{ lines.length }} lines</span>
</div>
<div class="lines-container">
<div
v-for="(line, idx) in lines"
:key="idx"
:class="['line-row', typeClass(line), { selected: selectedLine === idx }]"
@click="selectLine(idx)"
>
<span class="line-num">{{ idx + 1 }}</span>
<span class="line-preview">{{ getLinePreview(line) }}</span>
</div>
</div>
<!-- Expanded view for selected line -->
<div v-if="selectedLine !== null && lines[selectedLine]" class="expanded-view">
<div class="expanded-header">
<span>Line {{ selectedLine + 1 }}</span>
<button class="close-btn" @click="selectedLine = null">&times;</button>
</div>
<pre class="expanded-json">{{ formatJson(lines[selectedLine]) }}</pre>
</div>
</div>
</template>
<style scoped>
.raw-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.viewer-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.line-count {
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.lines-container {
flex: 1;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
}
.line-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.1s;
}
.line-row:hover {
background: var(--bg-hover);
}
.line-row.selected {
background: rgba(99, 102, 241, 0.1);
border-left-color: var(--accent);
}
.line-num {
color: var(--text-muted);
min-width: 36px;
text-align: right;
user-select: none;
opacity: 0.6;
}
.line-preview {
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Type-based colors */
.type-user .line-preview { color: #60a5fa; }
.type-assistant .line-preview { color: #34d399; }
.type-progress .line-preview { color: var(--text-muted); opacity: 0.7; }
.type-system .line-preview { color: #fbbf24; }
.type-file-history-snapshot .line-preview { color: var(--text-muted); opacity: 0.5; }
.expanded-view {
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
max-height: 40%;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.expanded-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 0.25rem;
}
.close-btn:hover {
color: var(--text-primary);
}
.expanded-json {
flex: 1;
overflow: auto;
margin: 0;
padding: 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,542 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import type { AgentName } from '@/types/transcript-debug'
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
import TerminalNavButtons from '../TerminalNavButtons.vue'
const props = defineProps<{
agent: AgentName
sessionId: string
terminal: EphemeralTerminal | null
}>()
const AGENT_CMD: Record<AgentName, string> = {
ejecutor: 'ejecutor',
nucleo000: 'nucleo000',
claude: 'claude'
}
const isOpen = ref(false)
// Local ref for xterm container - syncs to composable's containerRef
const terminalContainer = ref<HTMLElement | null>(null)
// Nav buttons toggle
const showNavButtons = ref(false)
// Drag state
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 620, h: 400 })
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
const windowRef = ref<HTMLElement | null>(null)
const statusDotClass = computed(() => {
if (!props.terminal) return ''
switch (props.terminal.state.value) {
case 'running':
case 'shell-ready': return 'on'
case 'connecting': return 'wait'
case 'exited': return 'error'
default: return ''
}
})
const terminalStyle = computed((): Record<string, string> => {
if (!hasCustomPosition.value) {
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
bottom: '80px',
right: '16px'
}
}
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
// ── Open / Close ──
async function open() {
if (isOpen.value) {
closeTerminal()
return
}
if (!props.terminal) return
isOpen.value = true
await nextTick()
// Sync container ref
if (terminalContainer.value) {
props.terminal.containerRef.value = terminalContainer.value
}
// Init renderer if needed
if (!props.terminal.renderer.isReady.value) {
props.terminal.renderer.init()
}
setTimeout(() => {
props.terminal?.renderer.fit()
props.terminal?.renderer.terminal.value?.refresh(0, (props.terminal.renderer.terminal.value?.rows ?? 1) - 1)
if (window.innerWidth > 1024) {
props.terminal?.renderer.focus()
}
}, 150)
}
function closeTerminal() {
isOpen.value = false
hasCustomPosition.value = false
showNavButtons.value = false
}
// ── Nav button actions ──
function navRunClaude() {
props.terminal?.sendInput(AGENT_CMD[props.agent])
}
function navRunClaudeContinue() {
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --continue')
}
function navRunClaudeResume() {
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --resume')
}
function navRefresh() {
props.terminal?.renderer.fit()
}
function navClearBuffer() {
props.terminal?.renderer.reset()
}
function navSendKey(key: string) {
const keyMap: Record<string, string> = {
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
}
const data = keyMap[key]
if (data) props.terminal?.renderer.terminal.value?.paste(data)
}
function navScroll(direction: 'up' | 'down' | 'end') {
if (!props.terminal) return
if (direction === 'up') props.terminal.renderer.scrollLines(-10)
else if (direction === 'down') props.terminal.renderer.scrollLines(10)
else props.terminal.renderer.scrollToBottom()
}
function toggleNavButtons() {
showNavButtons.value = !showNavButtons.value
}
// ── Drag ──
function startDrag(e: MouseEvent | TouchEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
if (e instanceof TouchEvent) e.preventDefault()
isDragging.value = true
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const rect = windowRef.value?.getBoundingClientRect()
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value) return
if (e instanceof TouchEvent) e.preventDefault()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const w = windowRef.value?.offsetWidth || 620
const h = windowRef.value?.offsetHeight || 400
position.value = {
x: Math.max(-w * 0.75, Math.min(clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
y: Math.max(-h * 0.75, Math.min(clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// ── Resize ──
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => props.terminal?.renderer.fit())
}
// Sync container ref when it mounts
watch(terminalContainer, (el) => {
if (props.terminal && el) {
props.terminal.containerRef.value = el
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<template>
<!-- Inline button -->
<button class="resume-terminal-btn" @click.stop="open" title="Open terminal (resume session)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</button>
<!-- Floating terminal modal -->
<Teleport to="body">
<Transition name="rt-slide">
<div
v-show="isOpen"
ref="windowRef"
class="resume-terminal"
:class="{ dragging: isDragging, resizing: isResizing }"
:style="terminalStyle"
>
<div class="rt-glass">
<!-- Titlebar -->
<div class="rt-titlebar" @mousedown="startDrag" @touchstart="startDrag">
<div class="rt-left">
<div class="rt-badge">
<svg width="8" height="8" 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>
</div>
<span class="rt-name">{{ AGENT_CMD[agent] }}</span>
<span class="rt-session">{{ sessionId.slice(0, 8) }}</span>
<i class="rt-dot" :class="statusDotClass"></i>
<span v-if="terminal?.state.value === 'connecting'" class="rt-status-text">Connecting...</span>
<span v-else-if="terminal?.state.value === 'shell-ready'" class="rt-status-text">Starting...</span>
<span v-else-if="terminal?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
</div>
<div class="window-controls">
<button
class="wc-btn nav-toggle"
:class="{ active: showNavButtons }"
title="Toggle navigation"
@click.stop="toggleNavButtons"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<button class="wc-btn x" title="Close" @click.stop="closeTerminal">
<svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</div>
</div>
<!-- Terminal content -->
<div class="rt-content">
<div ref="terminalContainer" class="rt-term"></div>
<!-- Overlay: connecting -->
<div v-if="terminal?.state.value === 'connecting'" class="rt-overlay connecting">
<div class="rt-overlay-msg">
<div class="rt-spinner"></div>
<span>Connecting...</span>
</div>
</div>
</div>
<!-- Resize handle -->
<div class="rt-resize" @mousedown="startResize"></div>
</div>
<!-- Nav buttons bar (outside glass, hangs from bottom) -->
<TerminalNavButtons
v-if="showNavButtons"
class="rt-nav-popup"
@request-token="terminal?.sendInput('genera token usando tu mcp')"
@run-claude="navRunClaude"
@run-claude-continue="navRunClaudeContinue"
@run-claude-resume="navRunClaudeResume"
@clear-buffer="navClearBuffer"
@refresh="navRefresh"
@send-key="navSendKey"
@scroll="navScroll"
/>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ── Inline button ── */
.resume-terminal-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
border-radius: 3px;
cursor: pointer;
color: var(--text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.resume-terminal-btn:hover {
background: var(--bg-hover);
color: var(--accent, #6366f1);
}
/* ── Floating terminal ── */
.resume-terminal {
position: fixed;
min-width: 400px;
min-height: 250px;
z-index: 10002;
}
.rt-glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(200, 215, 235, 0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 0 0 1px rgba(80, 120, 180, 0.25), 0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.rt-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 24px;
padding: 0 4px 0 6px;
background: rgba(255, 255, 255, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
cursor: grab;
user-select: none;
touch-action: none;
}
.resume-terminal.dragging .rt-titlebar { cursor: grabbing; }
.rt-left {
display: flex;
align-items: center;
gap: 6px;
font: 500 10px/1 system-ui, sans-serif;
color: #222;
min-width: 0;
}
.rt-badge {
width: 16px;
height: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
flex-shrink: 0;
}
.rt-name {
font-weight: 600;
font-size: 11px;
color: #333;
}
.rt-session {
font-size: 9px;
font-family: 'Courier New', monospace;
color: #666;
letter-spacing: 0.3px;
}
.rt-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #999;
flex-shrink: 0;
}
.rt-dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
.rt-dot.wait { background: #a80; animation: rt-pulse 0.8s infinite; }
.rt-dot.error { background: #e44; box-shadow: 0 0 4px #e44; }
.rt-status-text {
font-size: 9px;
color: #666;
}
.rt-status-text.exited { color: #c33; }
.window-controls { display: flex; gap: 1px; }
.wc-btn {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 2px;
color: #333;
cursor: pointer;
}
.wc-btn:hover { background: rgba(255, 255, 255, 0.5); }
.wc-btn.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
.wc-btn.nav-toggle { width: 44px; }
.wc-btn.nav-toggle.active { background: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
.wc-btn.nav-toggle:hover { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
.rt-content {
flex: 1;
margin: 2px;
border-radius: 2px;
overflow: hidden;
position: relative;
background: rgba(12, 12, 12, 0.95);
}
.rt-term { width: 100%; height: 100%; }
.rt-term :deep(.xterm) { height: 100%; padding: 2px; }
.rt-term :deep(.xterm-viewport) {
overflow-y: auto !important;
scrollbar-width: thin;
}
.rt-term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0, 0, 0, 0.2); }
.rt-term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
.rt-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.rt-overlay.connecting { cursor: wait; }
.rt-overlay-msg {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff;
text-align: center;
}
.rt-overlay-msg span { font-size: 13px; font-weight: 500; }
.rt-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: rt-spin 0.8s linear infinite;
}
.rt-resize {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 100%);
border-radius: 0 0 5px 0;
}
.rt-resize:hover { background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.2) 100%); }
.resume-terminal.resizing { user-select: none; }
.resume-terminal.resizing .rt-term { pointer-events: none; }
/* Nav buttons popup */
.rt-nav-popup {
position: absolute;
left: 0;
right: 0;
top: 100%;
border-radius: 0 0 6px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: none;
backdrop-filter: blur(14px) saturate(1.3);
-webkit-backdrop-filter: blur(14px) saturate(1.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1;
}
.rt-slide-enter-active, .rt-slide-leave-active { transition: all 0.15s ease; }
.rt-slide-enter-from, .rt-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
@keyframes rt-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes rt-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,528 @@
<script setup lang="ts">
import { computed, ref, toRef, onMounted, onUnmounted } from 'vue'
import type { AgentStatus, ActiveTool } from '@/stores/session-state'
import { useLifecycleStates, type ContinuousState } from '@/composables/useLifecycleStates'
// ── Event display config (for badges) ──
type LifecycleEvent =
| 'SessionStart' | 'UserPromptSubmit'
| 'PreToolUse' | 'PermissionRequest' | 'PostToolUse' | 'PostToolUseFailure'
| 'Notification' | 'SubagentStart' | 'SubagentStop'
| 'Stop' | 'TeammateIdle' | 'TaskCompleted'
| 'ConfigChange' | 'PreCompact' | 'SessionEnd'
type Category = 'session' | 'user' | 'tool' | 'agent' | 'system'
interface LifecycleInfo {
color: string
label: string
category: Category
}
const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
SessionStart: { color: '#60a5fa', label: 'Session started', category: 'session' },
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', category: 'user' },
PreToolUse: { color: '#fbbf24', label: 'Tool starting', category: 'tool' },
PermissionRequest: { color: '#fb923c', label: 'Permission required', category: 'tool' },
PostToolUse: { color: '#4ade80', label: 'Tool completed', category: 'tool' },
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', category: 'tool' },
Notification: { color: '#38bdf8', label: 'Notification', category: 'system' },
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', category: 'agent' },
SubagentStop: { color: '#a855f7', label: 'Subagent finished', category: 'agent' },
Stop: { color: '#22d3ee', label: 'Response complete', category: 'session' },
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', category: 'agent' },
TaskCompleted: { color: '#34d399', label: 'Task completed', category: 'system' },
ConfigChange: { color: '#e879f9', label: 'Config changed', category: 'system' },
PreCompact: { color: '#f59e0b', label: 'Compacting context', category: 'system' },
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' },
}
const CATEGORY_ORDER: Record<Category, number> = {
session: 0, user: 1, tool: 2, agent: 3, system: 4
}
interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
interface EventCountEntry {
event: LifecycleEvent
count: number
color: string
}
// ── Props ──
const props = defineProps<{
currentEvent?: string | null
eventDetail?: string
hookHistory?: HookHistoryEntry[]
// Server-derived continuous state
sessionActive: boolean
agentResponding: boolean
subagentActive: boolean
compacting: boolean
agentStatus: AgentStatus
currentTool: ActiveTool | null
lastActivity: number
}>()
// ── Continuous states (from server) ──
const { activeStates, isActive } = useLifecycleStates({
sessionActive: toRef(props, 'sessionActive'),
agentResponding: toRef(props, 'agentResponding'),
subagentActive: toRef(props, 'subagentActive'),
compacting: toRef(props, 'compacting'),
status: toRef(props, 'agentStatus'),
currentTool: toRef(props, 'currentTool'),
lastActivity: toRef(props, 'lastActivity'),
})
// ── Elapsed time ──
const now = ref(Date.now())
let elapsedTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
elapsedTimer = setInterval(() => { now.value = Date.now() }, 1000)
})
onUnmounted(() => { if (elapsedTimer) clearInterval(elapsedTimer) })
function hasElapsed(state: ContinuousState): boolean {
if (state.type !== 'responding' && state.type !== 'tool') return false
return (now.value - state.startedAt) >= 2000
}
function getElapsed(state: ContinuousState): string {
const ms = now.value - state.startedAt
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const rs = s % 60
return rs > 0 ? `${m}m ${rs}s` : `${m}m`
}
// Session chip compacts to dot-only when other states are active
const sessionCompact = computed(() =>
activeStates.value.length > 1 &&
activeStates.value.some(s => s.type === 'session')
)
// ── Fallback: last event label (when no continuous states) ──
const hasEvent = computed(() => !!props.currentEvent && props.currentEvent in LIFECYCLE_DISPLAY)
const activeEvent = computed<LifecycleEvent | null>(() => hasEvent.value ? props.currentEvent as LifecycleEvent : null)
const activeDetail = computed(() => props.eventDetail || '')
const displayInfo = computed(() => activeEvent.value ? LIFECYCLE_DISPLAY[activeEvent.value] : null)
const fallbackText = computed(() => {
if (!displayInfo.value) return ''
const label = displayInfo.value.label
const detail = activeDetail.value
return detail ? `${label}${detail}` : label
})
// ── Badge counts ──
function countEvents(entries: { event: string }[]): EventCountEntry[] {
const counts = new Map<string, number>()
for (const entry of entries) {
counts.set(entry.event, (counts.get(entry.event) || 0) + 1)
}
const result: EventCountEntry[] = []
for (const [event, count] of counts) {
if (event in LIFECYCLE_DISPLAY) {
result.push({
event: event as LifecycleEvent,
count,
color: LIFECYCLE_DISPLAY[event as LifecycleEvent].color,
})
}
}
result.sort((a, b) => {
const ca = LIFECYCLE_DISPLAY[a.event].category
const cb = LIFECYCLE_DISPLAY[b.event].category
if (CATEGORY_ORDER[ca] !== CATEGORY_ORDER[cb]) {
return CATEGORY_ORDER[ca] - CATEGORY_ORDER[cb]
}
return a.event.localeCompare(b.event)
})
return result
}
const displayCounts = computed(() => countEvents(props.hookHistory || []))
// Show the ribbon if there are active continuous states OR a fallback event
const showRibbon = computed(() => isActive.value || !!displayInfo.value)
// Border color follows the highest-priority active state or fallback
const ribbonBorderColor = computed(() => {
if (activeStates.value.length > 0) {
const last = activeStates.value[activeStates.value.length - 1]
return last?.color || 'rgba(255, 255, 255, 0.04)'
}
return displayInfo.value?.color || 'rgba(255, 255, 255, 0.04)'
})
</script>
<template>
<div
v-if="showRibbon"
class="lifecycle-ribbon"
:style="{ borderTopColor: ribbonBorderColor + '26' }"
>
<!-- Badge counts -->
<div class="lc-badges" v-if="displayCounts.length > 0">
<TransitionGroup name="badge">
<span
v-for="entry in displayCounts"
:key="entry.event"
class="lc-badge"
:style="{
background: entry.color + '1a',
color: entry.color,
borderColor: entry.color + '33',
}"
:title="entry.event + ': ' + entry.count"
>{{ entry.count }}</span>
</TransitionGroup>
</div>
<!-- Separator -->
<span
v-if="displayCounts.length > 0 && (activeStates.length > 0 || displayInfo)"
class="lc-sep"
>|</span>
<!-- Active continuous state chips -->
<div v-if="activeStates.length > 0" class="lc-states">
<TransitionGroup name="chip">
<span
v-for="state in activeStates"
:key="state.type"
class="lc-chip"
:style="{ '--chip-color': state.color } as any"
:title="state.label + (state.detail ? ' — ' + state.detail : '')"
>
<!-- Animated indicator -->
<span class="chip-indicator" :class="'anim-' + state.type">
<template v-if="state.type === 'responding'">
<span class="dot dot-1"></span>
<span class="dot dot-2"></span>
<span class="dot dot-3"></span>
</template>
</span>
<!-- Label (hidden for session when compact) -->
<span
v-if="!(state.type === 'session' && sessionCompact)"
class="chip-label"
>{{ state.label }}</span>
<!-- Elapsed time -->
<span v-if="hasElapsed(state)" class="chip-elapsed">{{ getElapsed(state) }}</span>
</span>
</TransitionGroup>
</div>
<!-- Fallback: show last event text when no continuous states -->
<Transition v-else name="lc" mode="out-in">
<span
v-if="displayInfo"
class="lc-label"
:key="(activeEvent || '') + activeDetail"
:style="{ color: displayInfo.color }"
>{{ fallbackText }}</span>
</Transition>
</div>
</template>
<style scoped>
.lifecycle-ribbon {
display: flex;
align-items: center;
min-height: 20px;
padding: 0.15rem 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.04);
transition: border-top-color 0.3s ease;
}
/* ── Badges ── */
.lc-badges {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
margin-right: 4px;
overflow: hidden;
}
.lc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 12px;
height: 12px;
padding: 0 2px;
font-size: 8px;
font-weight: 700;
font-family: 'Courier New', monospace;
border: 1px solid;
border-radius: 2px;
line-height: 1;
white-space: nowrap;
}
.badge-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.badge-leave-active { transition: opacity 0.15s ease; }
.badge-enter-from { opacity: 0; transform: scale(0.7); }
.badge-leave-to { opacity: 0; }
.badge-move { transition: transform 0.2s ease; }
/* ── Separator ── */
.lc-sep {
font-size: 8px;
color: rgba(255, 255, 255, 0.15);
margin: 0 3px;
flex-shrink: 0;
}
/* ── Continuous state chips ── */
.lc-states {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.lc-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px 1px 3px;
border-radius: 3px;
background: color-mix(in srgb, var(--chip-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
}
.chip-label {
font-size: 9px;
font-weight: 600;
font-family: 'Courier New', monospace;
color: var(--chip-color);
overflow: hidden;
text-overflow: ellipsis;
}
.chip-elapsed {
font-size: 8px;
font-weight: 400;
font-family: 'Courier New', monospace;
color: var(--chip-color);
opacity: 0.55;
margin-left: 1px;
}
.chip-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── Chip transitions ── */
.chip-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.chip-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.chip-enter-from {
opacity: 0;
transform: scale(0.8) translateY(2px);
}
.chip-leave-to {
opacity: 0;
transform: scale(0.8);
}
.chip-move {
transition: transform 0.25s ease;
}
/* ══════════════════════════════════════════
Per-state animations
══════════════════════════════════════════ */
/* ── Session: breathing dot ── */
.anim-session {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--chip-color);
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
/* ── Responding: typing dots ── */
.anim-responding {
display: inline-flex;
gap: 1.5px;
align-items: center;
}
.anim-responding .dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--chip-color);
animation: typing-dot 1.2s ease-in-out infinite;
}
.anim-responding .dot-2 { animation-delay: 0.2s; }
.anim-responding .dot-3 { animation-delay: 0.4s; }
@keyframes typing-dot {
0%, 60%, 100% { opacity: 0.2; transform: scale(0.75); }
30% { opacity: 1; transform: scale(1.15); }
}
/* ── Tool: shimmer bar ── */
.anim-tool {
width: 14px;
height: 3px;
border-radius: 1.5px;
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
position: relative;
overflow: hidden;
}
.anim-tool::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: var(--chip-color);
border-radius: 1.5px;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { left: -100%; }
50% { left: 100%; }
100% { left: 100%; }
}
/* ── Subagent: orbiting dot ── */
.anim-subagent {
width: 8px;
height: 8px;
position: relative;
}
.anim-subagent::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--chip-color);
opacity: 0.25;
top: 3px;
left: 3px;
}
.anim-subagent::after {
content: '';
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--chip-color);
top: 0;
left: 2.5px;
animation: orbit 1.5s linear infinite;
}
@keyframes orbit {
0% { top: 0px; left: 2.5px; }
25% { top: 2.5px; left: 5px; }
50% { top: 5px; left: 2.5px; }
75% { top: 2.5px; left: 0px; }
100% { top: 0px; left: 2.5px; }
}
/* ── Permission: urgent blink ── */
.anim-permission {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--chip-color);
animation: urgent-blink 0.6s ease-in-out infinite;
}
@keyframes urgent-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.12; }
}
/* ── Compacting: horizontal squeeze ── */
.anim-compacting {
width: 8px;
height: 5px;
background: var(--chip-color);
border-radius: 1px;
animation: squeeze 1.2s ease-in-out infinite;
}
@keyframes squeeze {
0%, 100% { transform: scaleX(1); opacity: 0.6; }
50% { transform: scaleX(0.45); opacity: 1; }
}
/* ── Fallback text label ── */
.lc-label {
font-size: 9px;
font-weight: 600;
font-family: 'Courier New', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Fallback label transitions */
.lc-enter-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.lc-leave-active { transition: opacity 0.1s ease; }
.lc-enter-from { opacity: 0; transform: translateY(4px); }
.lc-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { SessionInfo } from '@/types/transcript-debug'
defineProps<{
sessions: SessionInfo[]
selectedId: string | null
loading: boolean
}>()
const emit = defineEmits<{
select: [sessionId: string]
}>()
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return 'just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text
return text.slice(0, max) + '...'
}
</script>
<template>
<div class="session-selector">
<label class="selector-label">Session</label>
<select
class="session-select"
:value="selectedId || ''"
@change="emit('select', ($event.target as HTMLSelectElement).value)"
:disabled="loading"
>
<option value="" disabled>Select a transcript session...</option>
<option
v-for="s in sessions"
:key="s.id"
:value="s.id"
>
{{ s.firstUserMessage ? truncate(s.firstUserMessage, 60) : s.id.slice(0, 8) + '...' }} &mdash; {{ formatDate(s.mtimeISO) }} ({{ formatSize(s.size) }})
</option>
</select>
<span v-if="loading" class="loading-indicator">
<span class="spinner-sm"></span>
</span>
</div>
</template>
<style scoped>
.session-selector {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.selector-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.session-select {
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
.session-select:focus {
outline: none;
border-color: var(--accent);
}
.loading-indicator {
display: flex;
align-items: center;
}
.spinner-sm {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedSystemMessage } from '@/types/transcript-debug'
const props = defineProps<{
message: ParsedSystemMessage
}>()
const expanded = ref(false)
const hasContent = computed(() => !!props.message.content?.trim())
// ── Subtype display config ──
interface SubtypeDisplay {
label: string
color: string
icon: string // SVG path(s) for the icon
iconFill?: boolean // Whether inner elements use fill instead of stroke
}
const SUBTYPE_MAP: Record<string, SubtypeDisplay> = {
api_error: {
label: 'API error',
color: '#ef4444',
// X inside circle
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm3.5 12.5L13 12l2.5-2.5m-7 0L11 12l-2.5 2.5',
},
rate_limit: {
label: 'Rate limited',
color: '#f59e0b',
// Clock
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v5l3 3',
},
overloaded: {
label: 'Overloaded',
color: '#f59e0b',
// Warning triangle
icon: '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 0zM12 9v4m0 4h.01',
},
init: {
label: 'Init',
color: '#60a5fa',
// Power on
icon: 'M12 2v6m-6.36.64A9 9 0 1 0 18.36 8.64',
},
config: {
label: 'Config',
color: '#a78bfa',
// Settings gear
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z',
},
}
const DEFAULT_DISPLAY: SubtypeDisplay = {
label: 'System',
color: '#fbbf24',
// Info circle
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v1m0 4v4',
}
const display = computed(() => {
const sub = props.message.subtype
if (sub && SUBTYPE_MAP[sub]) return SUBTYPE_MAP[sub]
return DEFAULT_DISPLAY
})
const previewText = computed(() => {
const c = props.message.content?.trim() || ''
if (!c) return ''
// Single line preview
const line = c.split('\n')[0]
return line.length > 100 ? line.slice(0, 100) + '...' : line
})
function formatTime(ts: string): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
}
</script>
<template>
<div class="sys-row-wrapper">
<button
class="sys-row"
:class="{ expandable: hasContent, expanded }"
@click="hasContent && (expanded = !expanded)"
>
<!-- Icon -->
<span class="sys-icon" :style="{ color: display.color }">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path :d="display.icon" />
</svg>
</span>
<!-- Label -->
<span class="sys-label" :style="{ color: display.color }">{{ display.label }}</span>
<!-- Subtype badge (if different from label) -->
<span
v-if="message.subtype && !SUBTYPE_MAP[message.subtype]"
class="sys-subtype"
:style="{ color: display.color, background: display.color + '1a' }"
>{{ message.subtype }}</span>
<!-- Preview -->
<span v-if="previewText" class="sys-preview">{{ previewText }}</span>
<!-- Expand indicator -->
<span v-if="hasContent" class="sys-expand-hint">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline :points="expanded ? '18 15 12 9 6 15' : '6 9 12 15 18 9'" />
</svg>
</span>
<!-- Timestamp -->
<span class="sys-time">{{ formatTime(message.timestamp) }}</span>
</button>
<!-- Expanded content -->
<pre v-if="expanded && hasContent" class="sys-content" :style="{ borderColor: display.color + '33' }">{{ message.content }}</pre>
</div>
</template>
<style scoped>
.sys-row-wrapper {
margin: 0.1rem 0;
}
.sys-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.15rem 0.5rem;
background: none;
border: none;
font-size: 11px;
color: var(--text-muted);
opacity: 0.55;
transition: opacity 0.15s;
cursor: default;
text-align: left;
}
.sys-row.expandable {
cursor: pointer;
}
.sys-row:hover {
opacity: 0.85;
}
.sys-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.sys-label {
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
}
.sys-subtype {
font-size: 9px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
padding: 0 0.25rem;
border-radius: 3px;
flex-shrink: 0;
}
.sys-preview {
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.sys-expand-hint {
display: flex;
align-items: center;
color: var(--text-muted);
opacity: 0.5;
flex-shrink: 0;
transition: transform 0.15s;
}
.sys-time {
margin-left: auto;
font-size: 9px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
opacity: 0.7;
flex-shrink: 0;
}
/* ── Expanded content ── */
.sys-content {
margin: 0 0.5rem;
padding: 0.5rem 0.65rem;
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
border-left: 2px solid;
background: rgba(255, 255, 255, 0.02);
border-radius: 0 0 4px 4px;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import type { TerminalSlot } from '@/types/transcript-debug'
defineProps<{
terminals: TerminalSlot[]
activeSessionId: string | null
}>()
const emit = defineEmits<{
select: [sessionId: string]
'create-session': []
}>()
function stateColor(t: TerminalSlot): string {
if (!t.alive) return '#ef4444'
if (t.clients > 0) return '#22c55e'
return '#f59e0b'
}
// Pixel art SVG backgrounds for terminals 2-5
const artVariants = [
// T2: Coral reef — orange/pink corals, warm deep water
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%230c1a2e'/%3E%3Crect y='6' width='44' height='4' fill='%230e2040'/%3E%3Crect y='10' width='44' height='4' fill='%23122848'/%3E%3Crect y='14' width='44' height='4' fill='%23152e50'/%3E%3Crect y='18' width='44' height='4' fill='%23183458'/%3E%3Crect y='22' width='44' height='4' fill='%231a3860'/%3E%3Crect y='26' width='44' height='4' fill='%231c3c68'/%3E%3Crect y='30' width='44' height='4' fill='%231e3e6a'/%3E%3Crect y='34' width='44' height='10' fill='%232a1e10'/%3E%3Crect y='38' width='44' height='6' fill='%23342414'/%3E%3Crect x='6' y='26' width='3' height='8' fill='%23e85d2a' opacity='0.8'/%3E%3Crect x='5' y='24' width='2' height='3' fill='%23f07030' opacity='0.7'/%3E%3Crect x='8' y='23' width='2' height='4' fill='%23f07030' opacity='0.65'/%3E%3Crect x='4' y='22' width='1' height='3' fill='%23ff8844' opacity='0.5'/%3E%3Crect x='10' y='25' width='1' height='2' fill='%23ff8844' opacity='0.45'/%3E%3Crect x='18' y='28' width='4' height='6' fill='%23d4467a' opacity='0.8'/%3E%3Crect x='17' y='26' width='2' height='3' fill='%23e0558a' opacity='0.65'/%3E%3Crect x='21' y='25' width='2' height='4' fill='%23e0558a' opacity='0.6'/%3E%3Crect x='19' y='24' width='1' height='3' fill='%23f06898' opacity='0.45'/%3E%3Crect x='30' y='27' width='3' height='7' fill='%23c83030' opacity='0.75'/%3E%3Crect x='29' y='25' width='2' height='3' fill='%23d84040' opacity='0.6'/%3E%3Crect x='32' y='24' width='2' height='4' fill='%23d84040' opacity='0.55'/%3E%3Crect x='34' y='26' width='1' height='2' fill='%23e85050' opacity='0.45'/%3E%3Crect x='38' y='30' width='2' height='4' fill='%23e8752a' opacity='0.5'/%3E%3Crect x='37' y='28' width='1' height='3' fill='%23f08838' opacity='0.4'/%3E%3Crect x='12' y='16' width='1' height='1' fill='%2388ccff' opacity='0.3'/%3E%3Crect x='25' y='12' width='1' height='1' fill='%2388ccff' opacity='0.25'/%3E%3Crect x='36' y='8' width='1' height='1' fill='%2388ccff' opacity='0.2'/%3E%3Crect x='14' y='14' width='3' height='1' fill='%23ffaa44' opacity='0.4'/%3E%3Crect x='13' y='15' width='1' height='1' fill='%23ffaa44' opacity='0.3'/%3E%3Crect x='8' y='36' width='2' height='1' fill='%234a3820' opacity='0.4'/%3E%3Crect x='24' y='38' width='3' height='1' fill='%234a3820' opacity='0.3'/%3E%3Crect x='15' y='40' width='2' height='1' fill='%23c8a860' opacity='0.15'/%3E%3Crect x='36' y='41' width='2' height='1' fill='%23c8a860' opacity='0.12'/%3E%3C/svg%3E")`,
// T3: Deep sea — very dark, bioluminescent green/cyan dots
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='44' fill='%23020810'/%3E%3Crect y='4' width='44' height='4' fill='%23030a14'/%3E%3Crect y='8' width='44' height='4' fill='%23040c18'/%3E%3Crect y='12' width='44' height='4' fill='%23050e1c'/%3E%3Crect y='16' width='44' height='4' fill='%23061020'/%3E%3Crect y='20' width='44' height='4' fill='%23051018'/%3E%3Crect y='24' width='44' height='4' fill='%23040e16'/%3E%3Crect y='28' width='44' height='4' fill='%23030c14'/%3E%3Crect y='32' width='44' height='4' fill='%23030a12'/%3E%3Crect y='36' width='44' height='8' fill='%23020810'/%3E%3Crect x='8' y='10' width='2' height='2' fill='%2300ffaa' opacity='0.5'/%3E%3Crect x='7' y='9' width='1' height='1' fill='%2300ffaa' opacity='0.2'/%3E%3Crect x='10' y='11' width='1' height='1' fill='%2300ffaa' opacity='0.15'/%3E%3Crect x='28' y='6' width='2' height='2' fill='%2300e4ff' opacity='0.45'/%3E%3Crect x='27' y='5' width='1' height='1' fill='%2300e4ff' opacity='0.15'/%3E%3Crect x='30' y='7' width='1' height='1' fill='%2300e4ff' opacity='0.12'/%3E%3Crect x='16' y='20' width='2' height='2' fill='%2340ff90' opacity='0.4'/%3E%3Crect x='15' y='19' width='1' height='1' fill='%2340ff90' opacity='0.15'/%3E%3Crect x='36' y='16' width='2' height='2' fill='%2300ffcc' opacity='0.35'/%3E%3Crect x='35' y='15' width='1' height='1' fill='%2300ffcc' opacity='0.12'/%3E%3Crect x='4' y='28' width='2' height='2' fill='%2300e4ff' opacity='0.3'/%3E%3Crect x='22' y='32' width='2' height='2' fill='%2340ff90' opacity='0.35'/%3E%3Crect x='38' y='26' width='2' height='2' fill='%2300ffaa' opacity='0.3'/%3E%3Crect x='12' y='36' width='1' height='1' fill='%2300e4ff' opacity='0.2'/%3E%3Crect x='32' y='38' width='1' height='1' fill='%2340ff90' opacity='0.2'/%3E%3Crect x='20' y='14' width='1' height='1' fill='%2300ffcc' opacity='0.15'/%3E%3Crect x='40' y='34' width='1' height='1' fill='%2300ffaa' opacity='0.15'/%3E%3Crect x='2' y='18' width='1' height='1' fill='%2340ff90' opacity='0.12'/%3E%3Crect x='18' y='38' width='6' height='3' fill='%23081828' opacity='0.5'/%3E%3Crect x='19' y='36' width='3' height='2' fill='%230a1c30' opacity='0.4'/%3E%3Crect x='6' y='40' width='4' height='2' fill='%23081828' opacity='0.4'/%3E%3Crect x='34' y='40' width='5' height='2' fill='%23081828' opacity='0.35'/%3E%3C/svg%3E")`,
// T4: Tropical lagoon — turquoise water, golden sand
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%23104858'/%3E%3Crect y='6' width='44' height='4' fill='%23148898'/%3E%3Crect y='10' width='44' height='4' fill='%231898a8'/%3E%3Crect y='14' width='44' height='4' fill='%231ca8b8'/%3E%3Crect y='18' width='44' height='4' fill='%2320b8c8'/%3E%3Crect y='22' width='44' height='4' fill='%2318a0b0'/%3E%3Crect y='26' width='44' height='4' fill='%231490a0'/%3E%3Crect y='30' width='44' height='4' fill='%23108090'/%3E%3Crect y='34' width='44' height='4' fill='%23c8a850'/%3E%3Crect y='38' width='44' height='6' fill='%23d4b460'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.4'/%3E%3Crect x='12' y='3' width='1' height='1' fill='%23e0f0ff' opacity='0.3'/%3E%3Crect x='30' y='1' width='1' height='1' fill='%23e0f0ff' opacity='0.35'/%3E%3Crect x='4' y='8' width='6' height='2' fill='%2340d8e8' opacity='0.25'/%3E%3Crect x='16' y='9' width='8' height='2' fill='%2340d8e8' opacity='0.2'/%3E%3Crect x='30' y='8' width='6' height='2' fill='%2340d8e8' opacity='0.25'/%3E%3Crect x='8' y='16' width='4' height='1' fill='%23e0f8ff' opacity='0.15'/%3E%3Crect x='24' y='18' width='5' height='1' fill='%23e0f8ff' opacity='0.12'/%3E%3Crect x='6' y='24' width='3' height='1' fill='%23e0f8ff' opacity='0.1'/%3E%3Crect x='14' y='28' width='2' height='2' fill='%2388ddaa' opacity='0.25'/%3E%3Crect x='32' y='26' width='3' height='2' fill='%2388ddaa' opacity='0.2'/%3E%3Crect x='22' y='22' width='2' height='1' fill='%23ffcc44' opacity='0.2'/%3E%3Crect x='10' y='36' width='2' height='1' fill='%23b89840' opacity='0.4'/%3E%3Crect x='26' y='38' width='3' height='1' fill='%23b89840' opacity='0.35'/%3E%3Crect x='18' y='40' width='2' height='1' fill='%23e0c870' opacity='0.3'/%3E%3Crect x='36' y='36' width='2' height='1' fill='%23b89840' opacity='0.3'/%3E%3Crect x='4' y='34' width='3' height='2' fill='%2388ddaa' opacity='0.15'/%3E%3Crect x='38' y='34' width='2' height='2' fill='%2390cc88' opacity='0.12'/%3E%3Crect x='6' y='42' width='1' height='1' fill='%23e8d088' opacity='0.2'/%3E%3Crect x='34' y='42' width='1' height='1' fill='%23e8d088' opacity='0.18'/%3E%3C/svg%3E")`,
// T5: Arctic — ice blue, white icebergs
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%230a1828'/%3E%3Crect y='6' width='44' height='4' fill='%23102030'/%3E%3Crect y='10' width='44' height='4' fill='%23142838'/%3E%3Crect y='14' width='44' height='4' fill='%23183040'/%3E%3Crect y='18' width='44' height='4' fill='%231c3848'/%3E%3Crect y='22' width='44' height='4' fill='%23203e50'/%3E%3Crect y='26' width='44' height='4' fill='%23244458'/%3E%3Crect y='30' width='44' height='4' fill='%23284a60'/%3E%3Crect y='34' width='44' height='4' fill='%232c5068'/%3E%3Crect y='38' width='44' height='6' fill='%23182838'/%3E%3Crect x='4' y='12' width='8' height='6' fill='%23d0e8f4' opacity='0.8'/%3E%3Crect x='5' y='10' width='6' height='2' fill='%23e0f0ff' opacity='0.7'/%3E%3Crect x='6' y='8' width='4' height='2' fill='%23eef6ff' opacity='0.6'/%3E%3Crect x='4' y='18' width='8' height='2' fill='%23a0c8e0' opacity='0.4'/%3E%3Crect x='28' y='16' width='10' height='6' fill='%23d0e8f4' opacity='0.75'/%3E%3Crect x='29' y='14' width='8' height='2' fill='%23e0f0ff' opacity='0.65'/%3E%3Crect x='30' y='12' width='6' height='2' fill='%23eef6ff' opacity='0.55'/%3E%3Crect x='28' y='22' width='10' height='2' fill='%23a0c8e0' opacity='0.35'/%3E%3Crect x='16' y='24' width='6' height='4' fill='%23c8e0f0' opacity='0.5'/%3E%3Crect x='17' y='22' width='4' height='2' fill='%23d8ecf8' opacity='0.45'/%3E%3Crect x='16' y='28' width='6' height='1' fill='%2390b8d0' opacity='0.3'/%3E%3Crect x='8' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.5'/%3E%3Crect x='20' y='4' width='1' height='1' fill='%23e0f0ff' opacity='0.4'/%3E%3Crect x='36' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.45'/%3E%3Crect x='14' y='6' width='1' height='1' fill='%23e0f0ff' opacity='0.3'/%3E%3Crect x='24' y='8' width='1' height='1' fill='%23e0f0ff' opacity='0.25'/%3E%3Crect x='40' y='6' width='1' height='1' fill='%23e0f0ff' opacity='0.35'/%3E%3Crect x='2' y='36' width='1' height='1' fill='%2380a8c0' opacity='0.2'/%3E%3Crect x='22' y='38' width='1' height='1' fill='%2380a8c0' opacity='0.15'/%3E%3Crect x='38' y='36' width='1' height='1' fill='%2380a8c0' opacity='0.18'/%3E%3Crect x='10' y='40' width='1' height='1' fill='%2380a8c0' opacity='0.12'/%3E%3C/svg%3E")`,
]
</script>
<template>
<TransitionGroup name="fab-stack" tag="div" class="terminal-fab-stack">
<button
v-for="(t, idx) in terminals"
:key="t.sessionId"
class="terminal-fab"
:class="{ active: t.sessionId === activeSessionId }"
:style="{ backgroundImage: artVariants[idx] || artVariants[0], transitionDelay: `${idx * 30}ms` }"
:title="t.label || t.sessionId.slice(0, 8)"
@click="emit('select', t.sessionId)"
>
<span class="fab-number">{{ idx + 2 }}</span>
<span class="fab-dot" :style="{ background: stateColor(t) }" />
</button>
<!-- New session "+" button last in DOM = top in column-reverse -->
<button
key="__new__"
class="terminal-fab new-session-fab"
title="New session"
@click="emit('create-session')"
>
<svg class="plus-icon" width="18" height="18" viewBox="0 0 18 18" shape-rendering="crispEdges">
<rect x="8" y="2" width="2" height="14" fill="currentColor"/>
<rect x="2" y="8" width="14" height="2" fill="currentColor"/>
</svg>
</button>
</TransitionGroup>
</template>
<style scoped>
.terminal-fab-stack {
display: flex;
flex-direction: column-reverse;
gap: 6px;
align-items: center;
}
.terminal-fab {
position: relative;
width: 44px;
height: 44px;
border: 1px solid rgba(14, 165, 233, 0.15);
border-radius: 0;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 3px 4px;
transition: all 0.2s ease;
image-rendering: pixelated;
pointer-events: auto;
background-size: cover;
background-repeat: no-repeat;
filter: grayscale(1) brightness(0.6);
}
.terminal-fab:not(.active):hover {
transform: translateY(-2px);
border-color: rgba(14, 165, 233, 0.35);
filter: grayscale(0.5) brightness(0.8);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.5),
0 10px 24px rgba(0, 0, 0, 0.6),
0 0 14px rgba(14, 165, 233, 0.15);
}
.terminal-fab:active {
transform: scale(0.95);
}
.terminal-fab:focus,
.terminal-fab:focus-visible {
outline: none;
}
.terminal-fab.active {
border: 2px solid;
border-image: conic-gradient(
from var(--border-angle, 0deg),
rgba(34, 211, 238, 1),
rgba(99, 102, 241, 0.7),
rgba(34, 211, 238, 0.15),
rgba(99, 102, 241, 0.7),
rgba(34, 211, 238, 1)
) 1;
filter: none;
box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
animation: border-spin 3s linear infinite;
}
@property --border-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes border-spin {
to { --border-angle: 360deg; }
}
.fab-number {
font-size: 10px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.55);
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.terminal-fab:hover .fab-number {
color: rgba(255, 255, 255, 0.8);
}
.terminal-fab.active .fab-number {
color: #67e8f9;
}
.new-session-fab {
background: rgba(14, 165, 233, 0.08) !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
color: rgba(103, 232, 249, 0.5);
}
.new-session-fab:hover {
color: rgba(103, 232, 249, 0.9);
background: rgba(14, 165, 233, 0.18) !important;
}
.plus-icon {
filter: drop-shadow(0 0 3px rgba(103, 232, 249, 0.3));
}
.fab-dot {
position: absolute;
top: 3px;
right: 3px;
width: 5px;
height: 5px;
border-radius: 0;
box-shadow: 0 0 4px currentColor;
}
/* TransitionGroup animations */
.fab-stack-enter-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.fab-stack-leave-active {
transition: all 0.2s ease-in;
}
.fab-stack-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.8);
}
.fab-stack-leave-to {
opacity: 0;
transform: translateX(8px) scale(0.8);
}
.fab-stack-move {
transition: transform 0.25s ease;
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref } from 'vue'
import MarkdownContent from './MarkdownContent.vue'
defineProps<{
content: string
}>()
const expanded = ref(false)
</script>
<template>
<div class="thinking-block">
<button class="thinking-toggle" @click="expanded = !expanded">
<svg
:class="['chevron', { rotated: expanded }]"
width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="thinking-label">Thinking</span>
<span class="thinking-length">{{ content.length }} chars</span>
</button>
<div v-if="expanded" class="thinking-content">
<MarkdownContent :content="content" />
</div>
</div>
</template>
<style scoped>
.thinking-block {
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.75rem;
background: rgba(168, 85, 247, 0.06);
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
text-align: left;
}
.thinking-toggle:hover {
background: rgba(168, 85, 247, 0.1);
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
.thinking-label {
font-weight: 500;
color: #a855f7;
}
.thinking-length {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.thinking-content {
padding: 0.75rem;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
border-top: 1px solid rgba(168, 85, 247, 0.15);
background: rgba(168, 85, 247, 0.03);
}
</style>

View File

@@ -0,0 +1,395 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall, ParsedProgressEvent } from '@/types/transcript-debug'
import ToolResultBlock from './ToolResultBlock.vue'
import { highlightCode } from '@/utils/markdown'
const props = defineProps<{
call: ParsedToolCall
}>()
const inputExpanded = ref(false)
const progressExpanded = ref(false)
const hookEvents = computed(() =>
props.call.progressEvents.filter(e => e.dataType === 'hook_progress')
)
const mcpEvents = computed(() =>
props.call.progressEvents.filter(e => e.dataType === 'mcp_progress')
)
const mcpCompleted = computed(() =>
mcpEvents.value.find(e => e.mcpStatus === 'completed')
)
function hookLabel(e: ParsedProgressEvent): string {
if (!e.hookName) return e.hookEvent || 'hook'
// "PreToolUse:mcp__agent-ui__navigate_to" → "PreToolUse"
const event = e.hookEvent || e.hookName.split(':')[0]
return event
}
const highlightedInput = computed(() =>
highlightCode(JSON.stringify(props.call.input, null, 2), 'json')
)
</script>
<template>
<div class="tool-call">
<div class="tool-header">
<span class="tool-icon">&#9881;</span>
<span class="tool-name">{{ call.name }}</span>
<span v-if="call.result?.isError" class="error-indicator">error</span>
<span v-if="mcpCompleted?.mcpElapsedMs != null" class="timing-badge">
{{ mcpCompleted.mcpElapsedMs }}ms
</span>
<span v-if="mcpCompleted?.mcpServerName" class="server-badge">
{{ mcpCompleted.mcpServerName }}
</span>
</div>
<!-- Hook + MCP progress timeline -->
<div v-if="call.progressEvents.length" class="progress-timeline">
<button class="timeline-toggle" @click="progressExpanded = !progressExpanded">
<svg
:class="['chevron', { rotated: progressExpanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="timeline-summary">
<span v-for="he in hookEvents" :key="he.uuid" :class="['hook-pill', he.hookEvent?.toLowerCase()]">
{{ hookLabel(he) }}
</span>
<span v-for="me in mcpEvents" :key="me.uuid" :class="['mcp-pill', me.mcpStatus]">
MCP {{ me.mcpStatus }}
<span v-if="me.mcpElapsedMs != null" class="mcp-ms">{{ me.mcpElapsedMs }}ms</span>
</span>
</span>
</button>
<div v-if="progressExpanded" class="timeline-details">
<div v-for="e in call.progressEvents" :key="e.uuid" class="timeline-row">
<!-- Hook progress -->
<template v-if="e.dataType === 'hook_progress'">
<span class="tl-icon hook-icon">&#9881;</span>
<span :class="['tl-event', e.hookEvent?.toLowerCase()]">{{ e.hookEvent }}</span>
<span class="tl-name">{{ e.hookName }}</span>
<span v-if="e.command" class="tl-command" :title="e.command">
{{ e.command.length > 80 ? e.command.slice(0, 80) + '...' : e.command }}
</span>
</template>
<!-- MCP progress -->
<template v-else-if="e.dataType === 'mcp_progress'">
<span class="tl-icon mcp-icon">&#9889;</span>
<span :class="['tl-status', e.mcpStatus]">{{ e.mcpStatus }}</span>
<span class="tl-server">{{ e.mcpServerName }}</span>
<span class="tl-tool">{{ e.mcpToolName }}</span>
<span v-if="e.mcpElapsedMs != null" class="tl-elapsed">{{ e.mcpElapsedMs }}ms</span>
</template>
</div>
</div>
</div>
<!-- Tool input -->
<div class="tool-input-section">
<button class="input-toggle" @click="inputExpanded = !inputExpanded">
<svg
:class="['chevron', { rotated: inputExpanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Input</span>
</button>
<pre v-if="inputExpanded" class="input-json" v-html="highlightedInput"></pre>
</div>
<!-- Tool result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.tool-call {
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.tool-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tool-icon {
font-size: 12px;
opacity: 0.6;
}
.tool-name {
font-size: 12px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #f59e0b;
}
.error-indicator {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.timing-badge {
margin-left: auto;
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.server-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(56, 189, 248, 0.1);
color: #38bdf8;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Progress timeline */
.progress-timeline {
border-bottom: 1px solid var(--border-color);
}
.timeline-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.75rem;
background: rgba(99, 102, 241, 0.03);
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.timeline-toggle:hover {
background: rgba(99, 102, 241, 0.06);
}
.timeline-summary {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
/* Hook pills */
.hook-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
letter-spacing: 0.3px;
}
.hook-pill.pretooluse {
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
}
.hook-pill.posttooluse {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
.hook-pill.sessionstart {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
/* MCP pills */
.mcp-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.mcp-pill.started {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
.mcp-pill.completed {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.mcp-ms {
opacity: 0.8;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Expanded timeline rows */
.timeline-details {
padding: 0.25rem 0.5rem 0.4rem 1.5rem;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
}
.timeline-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 10px;
color: var(--text-muted);
border-left: 2px solid var(--border-color);
padding-left: 0.5rem;
margin-left: 0.25rem;
}
.tl-icon {
font-size: 10px;
flex-shrink: 0;
}
.hook-icon { color: #fbbf24; }
.mcp-icon { color: #38bdf8; }
.tl-event {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.tl-event.pretooluse { color: #fbbf24; }
.tl-event.posttooluse { color: #a855f7; }
.tl-event.sessionstart { color: #22c55e; }
.tl-name {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.tl-command {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
opacity: 0.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.tl-status {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.tl-status.started { color: #38bdf8; }
.tl-status.completed { color: #22c55e; }
.tl-server {
font-size: 9px;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background: rgba(56, 189, 248, 0.08);
color: #38bdf8;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
}
.tl-tool {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-elapsed {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #22c55e;
white-space: nowrap;
margin-left: auto;
}
/* Chevron */
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
/* Tool input section */
.tool-input-section {
border-bottom: 1px solid var(--border-color);
}
.input-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.input-toggle:hover {
background: var(--bg-hover);
}
.input-json {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
max-height: 250px;
overflow-y: auto;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
font-family: 'SF Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedToolResult } from '@/types/transcript-debug'
import CodeBlock from './CodeBlock.vue'
const props = defineProps<{
result: ParsedToolResult
}>()
const content = computed(() => props.result.content)
const lang = computed(() => {
const trimmed = content.value.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
JSON.parse(trimmed)
return 'json'
} catch { /* not valid JSON */ }
}
return ''
})
const displayCode = computed(() => {
if (lang.value === 'json') {
try {
return JSON.stringify(JSON.parse(content.value.trim()), null, 2)
} catch { /* fallback */ }
}
return content.value
})
</script>
<template>
<CodeBlock :code="displayCode" :lang="lang" max-height="300px" />
</template>

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedSystemMessage } from '@/types/transcript-debug'
const props = defineProps<{
message: ParsedSystemMessage
}>()
const duration = computed(() => {
const ms = props.message.durationMs
if (!ms) return ''
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
return `${m}m ${s % 60}s`
})
</script>
<template>
<div class="turn-end">
<div class="reef-line">
<!-- Left reef: anchored to left edge, extends right -->
<svg class="reef reef-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
<!-- Ocean stone floor (prismarine-inspired mosaic) -->
<defs>
<pattern id="pfl" x="0" y="0" width="12" height="2" patternUnits="userSpaceOnUse">
<rect width="12" height="2" fill="#0e3b3b"/>
<rect x="0" y="0" width="5" height="1" fill="#1f7270"/>
<rect x="6" y="0" width="5" height="1" fill="#2a8a7e"/>
<rect x="0" y="1" width="2" height="1" fill="#35a098"/>
<rect x="3" y="1" width="5" height="1" fill="#1f7270"/>
<rect x="9" y="1" width="2" height="1" fill="#2a8a7e"/>
</pattern>
</defs>
<rect x="0" y="12" width="200" height="2" fill="url(#pfl)" opacity="0.85"/>
<!-- Crystal highlights -->
<rect x="3" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.5"/>
<rect x="15" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="28" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="42" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="58" y="12" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="75" y="13" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="91" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="108" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="130" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.3"/>
<rect x="155" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.25"/>
<rect x="175" y="12" width="1" height="1" fill="#7edcd2" opacity="0.2"/>
<!-- Tall coral cluster (left edge) -->
<rect x="2" y="4" width="2" height="10" fill="#f87171" opacity="0.85"/>
<rect x="4" y="2" width="2" height="12" fill="#fb923c" opacity="0.8"/>
<rect x="6" y="5" width="2" height="9" fill="#f87171" opacity="0.75"/>
<rect x="8" y="7" width="2" height="7" fill="#ef4444" opacity="0.65"/>
<rect x="3" y="1" width="2" height="2" fill="#fca5a5" opacity="0.55"/>
<!-- Seaweed grove -->
<rect x="14" y="1" width="2" height="13" fill="#22c55e" opacity="0.75"/>
<rect x="16" y="3" width="2" height="11" fill="#4ade80" opacity="0.7"/>
<rect x="18" y="5" width="2" height="9" fill="#16a34a" opacity="0.65"/>
<rect x="13" y="0" width="2" height="2" fill="#86efac" opacity="0.5"/>
<!-- Orange fish school (3 fish) -->
<rect x="26" y="4" width="3" height="2" fill="#f97316" opacity="0.85"/>
<rect x="25" y="5" width="1" height="1" fill="#fb923c" opacity="0.7"/>
<rect x="30" y="6" width="3" height="2" fill="#f97316" opacity="0.75"/>
<rect x="29" y="7" width="1" height="1" fill="#fb923c" opacity="0.65"/>
<rect x="33" y="3" width="3" height="2" fill="#ea580c" opacity="0.7"/>
<rect x="32" y="4" width="1" height="1" fill="#fdba74" opacity="0.55"/>
<!-- Bubbles -->
<rect x="24" y="1" width="1" height="1" fill="white" opacity="0.4"/>
<rect x="22" y="3" width="1" height="1" fill="white" opacity="0.35"/>
<rect x="37" y="2" width="1" height="1" fill="white" opacity="0.35"/>
<!-- Purple brain coral -->
<rect x="42" y="7" width="4" height="5" fill="#a855f7" opacity="0.7"/>
<rect x="43" y="6" width="2" height="2" fill="#c084fc" opacity="0.65"/>
<rect x="46" y="8" width="2" height="4" fill="#7c3aed" opacity="0.55"/>
<rect x="44" y="12" width="2" height="2" fill="#6d28d9" opacity="0.4"/>
<!-- Jellyfish -->
<rect x="54" y="2" width="3" height="2" fill="#c084fc" opacity="0.65"/>
<rect x="55" y="1" width="1" height="1" fill="#e9d5ff" opacity="0.5"/>
<rect x="54" y="4" width="1" height="2" fill="#a855f7" opacity="0.4"/>
<rect x="56" y="4" width="1" height="2" fill="#a855f7" opacity="0.4"/>
<!-- Starfish -->
<rect x="62" y="10" width="3" height="2" fill="#fbbf24" opacity="0.65"/>
<rect x="63" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
<rect x="63" y="12" width="1" height="1" fill="#f59e0b" opacity="0.5"/>
<!-- Anemone -->
<rect x="70" y="6" width="2" height="8" fill="#ec4899" opacity="0.65"/>
<rect x="72" y="7" width="2" height="7" fill="#f472b6" opacity="0.55"/>
<rect x="69" y="5" width="2" height="2" fill="#f9a8d4" opacity="0.5"/>
<rect x="73" y="6" width="2" height="2" fill="#f9a8d4" opacity="0.5"/>
<!-- Blue fish -->
<rect x="80" y="5" width="3" height="2" fill="#3b82f6" opacity="0.75"/>
<rect x="79" y="6" width="1" height="1" fill="#93c5fd" opacity="0.65"/>
<!-- Seaweed tuft -->
<rect x="88" y="4" width="2" height="10" fill="#059669" opacity="0.6"/>
<rect x="90" y="6" width="2" height="8" fill="#10b981" opacity="0.5"/>
<!-- Small coral -->
<rect x="96" y="8" width="2" height="6" fill="#f87171" opacity="0.6"/>
<rect x="98" y="9" width="2" height="5" fill="#fb923c" opacity="0.5"/>
<!-- Seahorse -->
<rect x="106" y="4" width="2" height="2" fill="#fbbf24" opacity="0.65"/>
<rect x="106" y="6" width="2" height="3" fill="#f59e0b" opacity="0.55"/>
<rect x="107" y="9" width="1" height="2" fill="#d97706" opacity="0.5"/>
<!-- Bubbles -->
<rect x="104" y="1" width="1" height="1" fill="white" opacity="0.35"/>
<rect x="112" y="3" width="1" height="1" fill="white" opacity="0.3"/>
<!-- More coral -->
<rect x="118" y="7" width="2" height="7" fill="#0ea5e9" opacity="0.5"/>
<rect x="120" y="9" width="2" height="5" fill="#22d3ee" opacity="0.4"/>
<!-- Tiny fish -->
<rect x="130" y="6" width="2" height="1" fill="#f97316" opacity="0.55"/>
<rect x="140" y="4" width="2" height="1" fill="#818cf8" opacity="0.5"/>
<!-- Shell -->
<rect x="150" y="10" width="3" height="2" fill="#fde68a" opacity="0.5"/>
<rect x="151" y="9" width="1" height="1" fill="#fef3c7" opacity="0.4"/>
<!-- Fade-out elements -->
<rect x="160" y="8" width="2" height="6" fill="#22c55e" opacity="0.35"/>
<rect x="170" y="9" width="2" height="5" fill="#a855f7" opacity="0.3"/>
<rect x="180" y="7" width="1" height="1" fill="white" opacity="0.2"/>
<rect x="190" y="10" width="2" height="4" fill="#f87171" opacity="0.2"/>
</svg>
<!-- Center badge -->
<span v-if="duration" class="duration-badge">{{ duration }}</span>
<span v-else class="end-badge">~</span>
<!-- Right reef: anchored to right edge, extends left -->
<svg class="reef reef-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
<!-- Ocean stone floor (prismarine-inspired mosaic) -->
<defs>
<pattern id="pfr" x="0" y="0" width="12" height="2" patternUnits="userSpaceOnUse">
<rect width="12" height="2" fill="#0e3b3b"/>
<rect x="0" y="0" width="5" height="1" fill="#2a8a7e"/>
<rect x="6" y="0" width="5" height="1" fill="#1f7270"/>
<rect x="0" y="1" width="2" height="1" fill="#1f7270"/>
<rect x="3" y="1" width="5" height="1" fill="#35a098"/>
<rect x="9" y="1" width="2" height="1" fill="#1f7270"/>
</pattern>
</defs>
<rect x="0" y="12" width="200" height="2" fill="url(#pfr)" opacity="0.85"/>
<!-- Crystal highlights -->
<rect x="190" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.5"/>
<rect x="178" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="162" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="145" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="128" y="12" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="110" y="13" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="92" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="70" y="13" width="1" height="1" fill="#7edcd2" opacity="0.3"/>
<rect x="45" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.25"/>
<rect x="22" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.2"/>
<!-- Tall coral cluster (right edge) -->
<rect x="192" y="3" width="2" height="11" fill="#ec4899" opacity="0.85"/>
<rect x="194" y="5" width="2" height="9" fill="#f472b6" opacity="0.8"/>
<rect x="196" y="4" width="2" height="10" fill="#ec4899" opacity="0.75"/>
<rect x="190" y="6" width="2" height="8" fill="#db2777" opacity="0.65"/>
<rect x="193" y="1" width="2" height="3" fill="#fbcfe8" opacity="0.55"/>
<!-- Seaweed grove -->
<rect x="182" y="2" width="2" height="12" fill="#10b981" opacity="0.75"/>
<rect x="184" y="4" width="2" height="10" fill="#34d399" opacity="0.7"/>
<rect x="180" y="0" width="2" height="3" fill="#6ee7b7" opacity="0.5"/>
<!-- Purple fish school -->
<rect x="170" y="5" width="3" height="2" fill="#818cf8" opacity="0.85"/>
<rect x="173" y="6" width="1" height="1" fill="#a5b4fc" opacity="0.7"/>
<rect x="166" y="3" width="3" height="2" fill="#6366f1" opacity="0.75"/>
<rect x="169" y="4" width="1" height="1" fill="#c7d2fe" opacity="0.65"/>
<rect x="163" y="7" width="3" height="2" fill="#818cf8" opacity="0.7"/>
<rect x="166" y="8" width="1" height="1" fill="#a5b4fc" opacity="0.55"/>
<!-- Bubbles -->
<rect x="175" y="1" width="1" height="1" fill="white" opacity="0.4"/>
<rect x="178" y="3" width="1" height="1" fill="white" opacity="0.35"/>
<rect x="160" y="2" width="1" height="1" fill="white" opacity="0.35"/>
<!-- Orange fan coral -->
<rect x="152" y="6" width="4" height="6" fill="#fb923c" opacity="0.7"/>
<rect x="153" y="5" width="2" height="2" fill="#fdba74" opacity="0.65"/>
<rect x="150" y="8" width="2" height="4" fill="#ea580c" opacity="0.55"/>
<!-- Turtle -->
<rect x="140" y="4" width="4" height="2" fill="#22c55e" opacity="0.7"/>
<rect x="139" y="5" width="1" height="1" fill="#4ade80" opacity="0.55"/>
<rect x="144" y="5" width="1" height="1" fill="#4ade80" opacity="0.55"/>
<rect x="141" y="3" width="2" height="1" fill="#86efac" opacity="0.5"/>
<!-- Shell -->
<rect x="132" y="10" width="3" height="2" fill="#fde68a" opacity="0.55"/>
<rect x="133" y="9" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
<!-- Cyan coral -->
<rect x="124" y="7" width="2" height="7" fill="#0ea5e9" opacity="0.65"/>
<rect x="126" y="8" width="2" height="6" fill="#22d3ee" opacity="0.55"/>
<rect x="122" y="9" width="2" height="5" fill="#0284c7" opacity="0.5"/>
<!-- Red anemone -->
<rect x="112" y="6" width="2" height="8" fill="#f87171" opacity="0.6"/>
<rect x="114" y="7" width="2" height="7" fill="#fca5a5" opacity="0.5"/>
<rect x="111" y="5" width="2" height="2" fill="#fecaca" opacity="0.4"/>
<!-- Tiny fish -->
<rect x="104" y="5" width="2" height="1" fill="#f97316" opacity="0.55"/>
<!-- Seaweed -->
<rect x="96" y="5" width="2" height="9" fill="#059669" opacity="0.5"/>
<rect x="94" y="7" width="2" height="7" fill="#10b981" opacity="0.4"/>
<!-- Starfish -->
<rect x="86" y="10" width="3" height="2" fill="#fbbf24" opacity="0.5"/>
<rect x="87" y="9" width="1" height="1" fill="#fde68a" opacity="0.4"/>
<!-- Bubbles -->
<rect x="80" y="2" width="1" height="1" fill="white" opacity="0.3"/>
<rect x="72" y="4" width="1" height="1" fill="white" opacity="0.25"/>
<!-- Fade-out elements -->
<rect x="60" y="8" width="2" height="6" fill="#ec4899" opacity="0.3"/>
<rect x="46" y="9" width="2" height="5" fill="#22c55e" opacity="0.25"/>
<rect x="30" y="7" width="1" height="1" fill="white" opacity="0.2"/>
<rect x="14" y="10" width="2" height="4" fill="#0ea5e9" opacity="0.2"/>
</svg>
</div>
</div>
</template>
<style scoped>
.turn-end {
padding: 0.25rem 0;
user-select: none;
}
.reef-line {
display: flex;
align-items: center;
gap: 0;
height: 28px;
}
.reef {
flex: 1;
height: 100%;
image-rendering: pixelated;
overflow: hidden;
}
.duration-badge,
.end-badge {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(14, 165, 233, 0.85);
padding: 0 6px;
letter-spacing: 1px;
z-index: 1;
text-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
animation: badge-glow 3s ease-in-out infinite;
}
.end-badge {
font-size: 12px;
color: rgba(14, 165, 233, 0.5);
animation: badge-drift 4s ease-in-out infinite;
}
@keyframes badge-glow {
0%, 100% {
text-shadow: 0 0 6px rgba(14, 165, 233, 0.3);
color: rgba(14, 165, 233, 0.8);
}
50% {
text-shadow: 0 0 12px rgba(14, 165, 233, 0.6), 0 0 4px rgba(34, 211, 238, 0.3);
color: rgba(14, 165, 233, 0.95);
}
}
@keyframes badge-drift {
0%, 100% {
opacity: 0.5;
}
50% {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import VoiceMicButton from './VoiceMicButton.vue'
const props = defineProps<{
terminalReady?: boolean | null // null = no terminal, false = starting, true = ready
voiceTranscript?: string
isRecording?: boolean
voiceMode?: 'web' | 'whisper'
whisperStatus?: 'offline' | 'loading' | 'ready'
maxLines?: number
}>()
const maxH = computed(() => {
const lines = props.maxLines ?? 6
return lines <= 1 ? '1.5em' : `${lines * 1.5}em`
})
const emit = defineEmits<{
send: [message: string]
startRecording: []
stopRecording: []
}>()
const input = ref('')
// terminalReady: null = no terminal, false = starting, true = ready
const noTerminal = computed(() => props.terminalReady === null)
const canSend = computed(() => props.terminalReady === true)
const isDisabled = computed(() => !input.value.trim() || !canSend.value)
function handleSend() {
const msg = input.value.trim()
if (!msg || !canSend.value) return
emit('send', msg)
input.value = ''
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
const ta = e.target as HTMLTextAreaElement
const start = ta.selectionStart
const end = ta.selectionEnd
input.value = input.value.slice(0, start) + '\n' + input.value.slice(end)
nextTick(() => {
ta.selectionStart = ta.selectionEnd = start + 1
})
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Fill textarea with voice transcript
watch(() => props.voiceTranscript, (newText) => {
if (newText && newText.trim()) {
input.value = newText
}
})
</script>
<template>
<div class="user-input">
<div class="input-container" :class="{ disabled: !canSend }">
<textarea
v-model="input"
class="input-field"
:style="{ maxHeight: maxH }"
:placeholder="noTerminal ? 'No terminal — use + to create session' : 'Continue this conversation...'"
rows="1"
:disabled="!canSend"
@keydown="handleKeydown"
/>
<VoiceMicButton
v-if="voiceMode"
:is-recording="isRecording ?? false"
:voice-mode="voiceMode"
:whisper-status="whisperStatus ?? 'offline'"
:disabled="!canSend"
@start="emit('startRecording')"
@stop="emit('stopRecording')"
/>
<button
class="send-btn"
:disabled="isDisabled"
@click="handleSend"
title="Send prompt (resumes this session)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.user-input {
padding: 0.5rem 0.75rem 0.15rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.input-container {
display: flex;
align-items: flex-end;
gap: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.4rem 0.5rem;
transition: border-color 0.15s, opacity 0.15s;
}
.input-container:focus-within {
border-color: var(--accent);
}
.input-container.disabled {
opacity: 0.5;
}
.input-field {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
line-height: 1.5;
resize: none;
field-sizing: content;
min-height: 1lh;
overflow-y: auto;
padding: 0.15rem 0.25rem;
font-family: inherit;
}
.input-field::placeholder {
color: var(--text-muted);
}
.input-field:disabled {
cursor: not-allowed;
}
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--accent);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.send-btn:hover:not(:disabled) {
filter: brightness(1.15);
}
.send-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,502 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedUserMessage, SectionSummary } from '@/types/transcript-debug'
import MarkdownContent from './MarkdownContent.vue'
const props = defineProps<{
message: ParsedUserMessage
collapsed?: boolean
sectionCount?: number
sectionSummary?: SectionSummary
}>()
const emit = defineEmits<{
toggleCollapse: []
}>()
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()
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(n)
}
const totalTokens = computed(() => {
if (!props.sectionSummary) return 0
return props.sectionSummary.inputTokens + props.sectionSummary.outputTokens
})
</script>
<template>
<!-- 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">
<span class="role-badge">User</span>
<span v-if="message.isMeta" class="meta-badge">meta</span>
<span v-if="isOptimistic" class="sending-badge">
<span class="sending-dot"></span>
<span class="sending-dot"></span>
<span class="sending-dot"></span>
Sending
</span>
<button
v-if="sectionCount && sectionCount > 0"
:class="['collapse-btn', { collapsed }]"
@click.stop="emit('toggleCollapse')"
:title="collapsed ? 'Expand section' : 'Collapse section'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline :points="collapsed ? '9 18 15 12 9 6' : '6 9 12 15 18 9'" />
</svg>
<template v-if="collapsed && sectionSummary">
<span class="collapse-count">{{ sectionSummary.total }}</span>
<template v-if="sectionSummary.toolNames.length">
<span class="badge-sep">|</span>
<span v-for="t in sectionSummary.toolNames" :key="t" class="tool-chip">{{ t }}</span>
</template>
<template v-if="sectionSummary.hasErrors">
<span class="badge-sep">|</span>
<span class="error-chip">{{ sectionSummary.errorCount }} err</span>
</template>
<template v-if="totalTokens > 0">
<span class="badge-sep">|</span>
<span class="token-chip">{{ formatTokens(totalTokens) }} tok</span>
</template>
</template>
</button>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</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 {
display: none;
}
.divider-content {
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 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.role-badge {
font-size: 10px;
font-weight: 700;
color: #818cf8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-badge {
font-size: 9px;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background: transparent;
color: rgba(251, 191, 36, 0.7);
font-weight: 600;
}
.timestamp {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.divider-text {
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
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;
}
/* Collapse button */
.collapse-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 4px;
border: none;
border-radius: 3px;
background: transparent;
cursor: pointer;
color: var(--text-muted);
transition: all 0.15s;
flex-wrap: wrap;
}
.collapse-btn:hover {
background: rgba(129, 140, 248, 0.1);
color: #818cf8;
}
.collapse-btn svg {
transition: transform 0.2s ease;
}
.collapse-count {
font-size: 9px;
font-weight: 600;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.collapse-btn:hover .collapse-count {
color: #818cf8;
}
.badge-sep {
font-size: 9px;
color: var(--text-muted);
opacity: 0.35;
user-select: none;
}
.tool-chip {
font-size: 8px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #818cf8;
background: rgba(129, 140, 248, 0.1);
padding: 0 3px;
border-radius: 3px;
line-height: 1.4;
}
.collapse-btn:hover .tool-chip {
color: #a5b4fc;
background: rgba(129, 140, 248, 0.18);
}
.error-chip {
font-size: 8px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #f87171;
background: rgba(239, 68, 68, 0.12);
padding: 0 3px;
border-radius: 3px;
line-height: 1.4;
}
.collapse-btn:hover .error-chip {
color: #fca5a5;
background: rgba(239, 68, 68, 0.2);
}
.token-chip {
font-size: 8px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
line-height: 1.4;
}
.collapse-btn:hover .token-chip {
color: #818cf8;
}
/* Optimistic / sending */
.user-divider.optimistic {
opacity: 0.6;
}
.sending-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 9px;
color: var(--accent, #6366f1);
font-weight: 500;
}
.sending-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--accent, #6366f1);
animation: sending-pulse 1.2s ease-in-out infinite;
}
.sending-dot:nth-child(2) { animation-delay: 0.15s; }
.sending-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes sending-pulse {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
</style>

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

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, computed } from 'vue'
import { useAquaticEvents } from './useAquaticEvents'
import { useAquaticState } from './useAquaticState'
import {
OceanScene,
BubbleStream,
FishSchool,
JellyfishDrift,
EventOverlay,
EdgeFade,
PixelLife,
} from './layers'
const { start, stop } = useAquaticEvents()
const { timeOfDay, season, depthZone } = useAquaticState()
const rootClasses = computed(() => [
`tod-${timeOfDay.value}`,
`season-${season.value}`,
`depth-${depthZone.value}`,
])
onMounted(() => start())
onBeforeUnmount(() => stop())
</script>
<template>
<div class="aquatic-bg" :class="rootClasses">
<!-- The world: unified background scene -->
<OceanScene />
<!-- Independent dynamic overlay layers -->
<BubbleStream />
<FishSchool />
<JellyfishDrift />
<PixelLife />
<EventOverlay />
<EdgeFade />
</div>
</template>
<style scoped>
.aquatic-bg {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.aquatic-bg :deep(*) {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as AquaticBackground } from './AquaticBackground.vue'

View File

@@ -0,0 +1,69 @@
<template>
<div class="bubble-stream">
<div class="bubbles-base" />
<div v-if="burstActive" class="bubbles-burst" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAquaticState } from '../useAquaticState'
const { activeEventModifiers } = useAquaticState()
const burstActive = computed(() => activeEventModifiers.value.has('bubble-burst'))
</script>
<style scoped>
.bubble-stream {
position: absolute;
inset: 0;
z-index: 6;
pointer-events: none;
}
.bubbles-base {
position: absolute;
inset: 0;
background:
/* Large bubbles - slow rise */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='220' viewBox='0 0 90 220' shape-rendering='crispEdges'%3E%3Crect x='22' y='30' width='4' height='4' fill='%2367e8f9' opacity='0.22'/%3E%3Crect x='65' y='95' width='4' height='4' fill='%2367e8f9' opacity='0.18'/%3E%3Crect x='38' y='160' width='4' height='4' fill='%2367e8f9' opacity='0.24'/%3E%3Crect x='10' y='200' width='4' height='4' fill='%2367e8f9' opacity='0.16'/%3E%3Crect x='75' y='50' width='4' height='4' fill='%2367e8f9' opacity='0.14'/%3E%3C/svg%3E") repeat / 90px 220px,
/* Medium bubbles - medium rise */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='110' height='300' viewBox='0 0 110 300' shape-rendering='crispEdges'%3E%3Crect x='30' y='40' width='2' height='2' fill='white' opacity='0.16'/%3E%3Crect x='80' y='120' width='2' height='2' fill='white' opacity='0.13'/%3E%3Crect x='15' y='190' width='2' height='2' fill='white' opacity='0.16'/%3E%3Crect x='60' y='260' width='2' height='2' fill='white' opacity='0.12'/%3E%3Crect x='95' y='70' width='2' height='2' fill='white' opacity='0.14'/%3E%3Crect x='45' y='160' width='2' height='2' fill='white' opacity='0.1'/%3E%3C/svg%3E") repeat / 110px 300px,
/* Tiny bubbles - fast rise */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='70' height='160' viewBox='0 0 70 160' shape-rendering='crispEdges'%3E%3Crect x='15' y='20' width='2' height='2' fill='%2367e8f9' opacity='0.1'/%3E%3Crect x='50' y='60' width='2' height='2' fill='%2367e8f9' opacity='0.08'/%3E%3Crect x='30' y='100' width='2' height='2' fill='white' opacity='0.09'/%3E%3Crect x='60' y='140' width='2' height='2' fill='%2367e8f9' opacity='0.07'/%3E%3Crect x='8' y='80' width='2' height='2' fill='white' opacity='0.08'/%3E%3C/svg%3E") repeat / 70px 160px;
animation: sea-bubbles 16s linear infinite, water-sway 10s ease-in-out infinite alternate;
}
/* Burst event: extra large bubbles from a point */
.bubbles-burst {
position: absolute;
inset: 0;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='180' viewBox='0 0 60 180' shape-rendering='crispEdges'%3E%3Crect x='25' y='20' width='6' height='6' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='15' y='70' width='6' height='6' fill='%2367e8f9' opacity='0.28'/%3E%3Crect x='35' y='110' width='6' height='6' fill='%2367e8f9' opacity='0.32'/%3E%3Crect x='20' y='150' width='6' height='6' fill='white' opacity='0.25'/%3E%3Crect x='40' y='40' width='4' height='4' fill='white' opacity='0.22'/%3E%3C/svg%3E") repeat / 60px 180px;
animation: burst-rise 6s linear infinite;
opacity: 0;
animation: burst-rise 6s linear infinite, burst-fade 8s ease-in-out forwards;
}
@keyframes sea-bubbles {
from { background-position: 0 0, 30px 0, 10px 0; }
to { background-position: 0 -220px, 0 -300px, -15px -160px; }
}
@keyframes water-sway {
from { transform: translateX(-3px); }
to { transform: translateX(3px); }
}
@keyframes burst-rise {
from { background-position: 30px 0; }
to { background-position: 30px -180px; }
}
@keyframes burst-fade {
0% { opacity: 0; }
10% { opacity: 0.8; }
80% { opacity: 0.6; }
100% { opacity: 0; }
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="edge-fade" />
</template>
<style scoped>
.edge-fade {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
}
.edge-fade::before,
.edge-fade::after {
content: '';
position: absolute;
left: 0;
right: 0;
pointer-events: none;
}
.edge-fade::before {
top: 0;
height: 25%;
background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, transparent 100%);
}
.edge-fade::after {
bottom: 0;
height: 25%;
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, transparent 100%);
}
</style>

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAquaticState } from '../useAquaticState'
const { activeEventModifiers } = useAquaticState()
const eventClasses = computed(() => {
const cls: Record<string, boolean> = {}
for (const [, cssClass] of activeEventModifiers.value) {
cls[cssClass] = true
}
return cls
})
</script>
<template>
<div class="event-overlay" :class="eventClasses">
<!-- Whale shadow -->
<div v-if="eventClasses['evt-whale-shadow']" class="whale-shadow" />
<!-- Bioluminescent flash -->
<div v-if="eventClasses['evt-bioluminescent-flash']" class="bio-flash" />
<!-- Plankton drift -->
<div v-if="eventClasses['evt-plankton-drift']" class="plankton" />
<!-- Fish chase -->
<div v-if="eventClasses['evt-fish-chase']" class="fish-chase" />
<!-- Turtle crossing -->
<div v-if="eventClasses['evt-turtle-crossing']" class="turtle" />
<!-- Manta ray -->
<div v-if="eventClasses['evt-manta-ray']" class="manta" />
<!-- Color shift (gradient overlay) -->
<div v-if="eventClasses['evt-color-shift']" class="color-shift" />
<!-- Aurora underwater -->
<div v-if="eventClasses['evt-aurora-underwater']" class="aurora" />
<!-- Mythical creature -->
<div v-if="eventClasses['evt-mythical-creature']" class="mythical" />
<!-- Volcanic vent -->
<div v-if="eventClasses['evt-volcanic-vent']" class="volcanic" />
<!-- Crystal formation -->
<div v-if="eventClasses['evt-crystal-formation']" class="crystals" />
<!-- Kelp forest -->
<div v-if="eventClasses['evt-kelp-forest']" class="kelp-extra" />
</div>
</template>
<style scoped>
.event-overlay {
position: absolute;
inset: 0;
z-index: 9;
pointer-events: none;
}
.event-overlay > div {
position: absolute;
inset: 0;
pointer-events: none;
}
/* ── Whale Shadow ── */
.whale-shadow {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='40' viewBox='0 0 120 40' shape-rendering='crispEdges'%3E%3Cellipse cx='60' cy='20' rx='58' ry='18' fill='%23000' opacity='0.2'/%3E%3Cellipse cx='55' cy='20' rx='45' ry='14' fill='%23000' opacity='0.15'/%3E%3Crect x='100' y='8' width='16' height='6' fill='%23000' opacity='0.15'/%3E%3Crect x='100' y='26' width='16' height='6' fill='%23000' opacity='0.15'/%3E%3C/svg%3E") no-repeat / 180px 60px;
animation: whale-cross 20s linear forwards;
opacity: 0;
}
@keyframes whale-cross {
0% { background-position: -200px 15%; opacity: 0; }
5% { opacity: 0.8; }
90% { opacity: 0.7; }
100% { background-position: calc(100% + 200px) 20%; opacity: 0; }
}
/* ── Bioluminescent Flash ── */
.bio-flash {
background: radial-gradient(
ellipse 40% 50% at 50% 75%,
rgba(74, 222, 128, 0.12) 0%,
rgba(34, 211, 238, 0.06) 40%,
transparent 70%
);
animation: bio-pulse 3s ease-in-out infinite alternate;
}
@keyframes bio-pulse {
from { opacity: 0.4; }
to { opacity: 1; }
}
/* ── Plankton Drift ── */
.plankton {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200' shape-rendering='crispEdges'%3E%3Crect x='20' y='30' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='80' y='50' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='140' y='20' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='50' y='90' width='1' height='1' fill='%2367e8f9' opacity='0.12'/%3E%3Crect x='170' y='80' width='1' height='1' fill='white' opacity='0.16'/%3E%3Crect x='30' y='140' width='1' height='1' fill='%2367e8f9' opacity='0.14'/%3E%3Crect x='110' y='120' width='1' height='1' fill='white' opacity='0.17'/%3E%3Crect x='160' y='160' width='1' height='1' fill='white' opacity='0.13'/%3E%3Crect x='60' y='170' width='1' height='1' fill='%2367e8f9' opacity='0.15'/%3E%3Crect x='120' y='180' width='1' height='1' fill='white' opacity='0.11'/%3E%3C/svg%3E") repeat;
animation: plankton-float 12s linear forwards;
opacity: 0;
}
@keyframes plankton-float {
0% { background-position: 0 0; opacity: 0; }
10% { opacity: 0.7; }
85% { opacity: 0.5; }
100% { background-position: -80px -40px; opacity: 0; }
}
/* ── Fish Chase ── */
.fish-chase {
opacity: 0;
animation: chase-sequence 10s linear forwards;
background:
/* Chaser fish (faster) */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='1' fill='%23ef4444' opacity='0.6'/%3E%3Crect x='0' y='4' width='2' height='1' fill='%23ef4444' opacity='0.6'/%3E%3Crect x='2' y='1' width='6' height='4' fill='%23f87171' opacity='0.55'/%3E%3Crect x='7' y='2' width='1' height='1' fill='%23000' opacity='0.5'/%3E%3C/svg%3E") no-repeat / 20px 12px,
/* Fleeing fish (slightly ahead) */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%23fbbf24' opacity='0.55'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%23fbbf24' opacity='0.55'/%3E%3Crect x='1' y='0' width='5' height='5' fill='%23f59e0b' opacity='0.5'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.45'/%3E%3C/svg%3E") no-repeat / 16px 10px;
}
@keyframes chase-sequence {
0% { background-position: -40px 45%, -20px 44%; opacity: 0; }
5% { opacity: 0.9; }
85% { opacity: 0.8; }
100% { background-position: calc(100% + 60px) 40%, calc(100% + 40px) 42%; opacity: 0; }
}
/* ── Turtle Crossing ── */
.turtle {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='14' viewBox='0 0 20 14' shape-rendering='crispEdges'%3E%3Crect x='6' y='3' width='8' height='8' fill='%2316a34a' opacity='0.5'/%3E%3Crect x='7' y='4' width='6' height='6' fill='%234ade80' opacity='0.4'/%3E%3Crect x='8' y='5' width='2' height='2' fill='%2322c55e' opacity='0.35'/%3E%3Crect x='11' y='6' width='2' height='2' fill='%2322c55e' opacity='0.35'/%3E%3Crect x='14' y='6' width='4' height='2' fill='%234ade80' opacity='0.45'/%3E%3Crect x='17' y='5' width='2' height='1' fill='%2316a34a' opacity='0.4'/%3E%3Crect x='18' y='6' width='1' height='1' fill='%23000' opacity='0.3'/%3E%3Crect x='2' y='4' width='4' height='2' fill='%234ade80' opacity='0.4'/%3E%3Crect x='2' y='8' width='4' height='2' fill='%234ade80' opacity='0.4'/%3E%3Crect x='10' y='2' width='3' height='2' fill='%234ade80' opacity='0.35'/%3E%3Crect x='10' y='10' width='3' height='2' fill='%234ade80' opacity='0.35'/%3E%3C/svg%3E") no-repeat / 40px 28px;
animation: turtle-swim 25s linear forwards;
opacity: 0;
}
@keyframes turtle-swim {
0% { background-position: -50px 30%; opacity: 0; }
5% { opacity: 0.8; }
90% { opacity: 0.7; }
100% { background-position: calc(100% + 50px) 35%; opacity: 0; }
}
/* ── Manta Ray ── */
.manta {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='12' viewBox='0 0 30 12' shape-rendering='crispEdges'%3E%3Crect x='10' y='4' width='10' height='4' fill='%23334155' opacity='0.45'/%3E%3Crect x='8' y='3' width='14' height='6' fill='%23475569' opacity='0.4'/%3E%3Crect x='4' y='2' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='20' y='2' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='0' y='1' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='25' y='1' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='4' y='7' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='20' y='7' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='0' y='9' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='25' y='9' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='14' y='5' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 60px 24px;
animation: manta-glide 18s linear forwards;
opacity: 0;
}
@keyframes manta-glide {
0% { background-position: calc(100% + 80px) 18%; opacity: 0; }
5% { opacity: 0.8; }
90% { opacity: 0.7; }
100% { background-position: -80px 22%; opacity: 0; }
}
/* ── Color Shift ── */
.color-shift {
background: linear-gradient(
180deg,
rgba(56, 189, 248, 0.03) 0%,
rgba(14, 165, 233, 0.05) 50%,
rgba(6, 182, 212, 0.04) 100%
);
animation: color-shift-fade 30s ease-in-out forwards;
}
@keyframes color-shift-fade {
0% { opacity: 0; }
15% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
/* ── Aurora Underwater ── */
.aurora {
background:
linear-gradient(0deg,
transparent 0%,
rgba(139, 92, 246, 0.04) 15%,
rgba(34, 211, 238, 0.05) 25%,
rgba(52, 211, 153, 0.04) 35%,
transparent 50%
);
animation: aurora-wave 8s ease-in-out infinite alternate, aurora-fade 300s ease-in-out forwards;
}
@keyframes aurora-wave {
from { background-position: 0 0; filter: hue-rotate(0deg); }
to { background-position: 0 -20px; filter: hue-rotate(30deg); }
}
@keyframes aurora-fade {
0% { opacity: 0; }
5% { opacity: 1; }
95% { opacity: 1; }
100% { opacity: 0; }
}
/* ── Mythical Creature (large serpent silhouette) ── */
.mythical {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='30' viewBox='0 0 160 30' shape-rendering='crispEdges'%3E%3Crect x='0' y='12' width='8' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='8' y='10' width='12' height='10' fill='%23312e81' opacity='0.22'/%3E%3Crect x='20' y='13' width='10' height='4' fill='%23312e81' opacity='0.2'/%3E%3Crect x='30' y='8' width='14' height='14' fill='%23312e81' opacity='0.25'/%3E%3Crect x='44' y='12' width='12' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='56' y='6' width='16' height='18' fill='%23312e81' opacity='0.28'/%3E%3Crect x='72' y='11' width='14' height='8' fill='%23312e81' opacity='0.22'/%3E%3Crect x='86' y='4' width='18' height='22' fill='%23312e81' opacity='0.3'/%3E%3Crect x='104' y='10' width='12' height='10' fill='%23312e81' opacity='0.25'/%3E%3Crect x='116' y='7' width='14' height='16' fill='%23312e81' opacity='0.27'/%3E%3Crect x='130' y='12' width='10' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='140' y='14' width='8' height='4' fill='%23312e81' opacity='0.18'/%3E%3Crect x='148' y='15' width='6' height='2' fill='%23312e81' opacity='0.15'/%3E%3Crect x='154' y='14' width='4' height='4' fill='%23312e81' opacity='0.18'/%3E%3Crect x='156' y='13' width='2' height='1' fill='%23818cf8' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 240px 45px;
animation: mythical-cross 30s linear forwards;
opacity: 0;
}
@keyframes mythical-cross {
0% { background-position: -260px 40%; opacity: 0; }
5% { opacity: 0.7; }
90% { opacity: 0.6; }
100% { background-position: calc(100% + 260px) 35%; opacity: 0; }
}
/* ── Volcanic Vent ── */
.volcanic {
background:
radial-gradient(
ellipse 30% 20% at 50% 95%,
rgba(249, 115, 22, 0.15) 0%,
rgba(239, 68, 68, 0.08) 40%,
transparent 70%
);
animation: volcanic-glow 4s ease-in-out infinite alternate, volcanic-fade 120s ease-in-out forwards;
}
@keyframes volcanic-glow {
from { opacity: 0.6; filter: brightness(0.9); }
to { opacity: 1; filter: brightness(1.1); }
}
@keyframes volcanic-fade {
0% { opacity: 0; }
5% { opacity: 1; }
90% { opacity: 1; }
100% { opacity: 0; }
}
/* ── Crystal Formation ── */
.crystals {
bottom: 0;
height: 25%;
top: auto;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='60' viewBox='0 0 300 60' shape-rendering='crispEdges'%3E%3Crect x='40' y='30' width='4' height='20' fill='%2367e8f9' opacity='0.25'/%3E%3Crect x='42' y='25' width='2' height='8' fill='%23a5f3fc' opacity='0.2'/%3E%3Crect x='38' y='35' width='2' height='10' fill='%2322d3ee' opacity='0.18'/%3E%3Crect x='120' y='28' width='6' height='24' fill='%23a78bfa' opacity='0.22'/%3E%3Crect x='123' y='22' width='2' height='10' fill='%23c4b5fd' opacity='0.18'/%3E%3Crect x='118' y='32' width='2' height='14' fill='%238b5cf6' opacity='0.15'/%3E%3Crect x='200' y='32' width='4' height='18' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='202' y='28' width='2' height='6' fill='white' opacity='0.15'/%3E%3Crect x='260' y='35' width='5' height='16' fill='%23a78bfa' opacity='0.2'/%3E%3Crect x='262' y='30' width='2' height='8' fill='%23c4b5fd' opacity='0.16'/%3E%3C/svg%3E") repeat-x bottom center / 300px 60px;
animation: crystal-appear 240s ease-in-out forwards, crystal-sparkle 2s ease-in-out infinite alternate;
}
@keyframes crystal-appear {
0% { opacity: 0; }
5% { opacity: 1; }
92% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes crystal-sparkle {
from { filter: brightness(1); }
to { filter: brightness(1.15); }
}
/* ── Kelp Forest (extra seaweed) ── */
.kelp-extra {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='120' viewBox='0 0 300 120' shape-rendering='crispEdges'%3E%3Crect x='30' y='20' width='2' height='100' fill='%2316a34a' opacity='0.35'/%3E%3Crect x='32' y='14' width='2' height='20' fill='%2322c55e' opacity='0.3'/%3E%3Crect x='28' y='30' width='2' height='15' fill='%2315803d' opacity='0.28'/%3E%3Crect x='100' y='10' width='2' height='110' fill='%2322c55e' opacity='0.32'/%3E%3Crect x='102' y='5' width='2' height='18' fill='%234ade80' opacity='0.28'/%3E%3Crect x='98' y='25' width='2' height='12' fill='%2316a34a' opacity='0.25'/%3E%3Crect x='180' y='25' width='2' height='95' fill='%2316a34a' opacity='0.33'/%3E%3Crect x='182' y='18' width='2' height='16' fill='%2322c55e' opacity='0.28'/%3E%3Crect x='250' y='15' width='2' height='105' fill='%2322c55e' opacity='0.3'/%3E%3Crect x='252' y='8' width='2' height='14' fill='%234ade80' opacity='0.25'/%3E%3Crect x='248' y='30' width='2' height='10' fill='%2315803d' opacity='0.22'/%3E%3C/svg%3E") repeat-x bottom center / 300px 120px;
animation: kelp-fade 150s ease-in-out forwards, kelp-sway 12s ease-in-out infinite alternate;
}
@keyframes kelp-fade {
0% { opacity: 0; }
5% { opacity: 1; }
90% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes kelp-sway {
from { transform: skewX(-1deg); }
to { transform: skewX(1deg); }
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div class="fish-school">
<div class="fish-main" />
<div class="fish-secondary" />
<div v-if="schoolActive" class="fish-event-school" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAquaticState } from '../useAquaticState'
const { activeEventModifiers } = useAquaticState()
const schoolActive = computed(() => activeEventModifiers.value.has('school-of-fish'))
</script>
<style scoped>
.fish-school {
position: absolute;
inset: 0;
z-index: 7;
pointer-events: none;
}
/* Fish 1: orange tropical (right-facing) + Fish 2: blue indigo (left-facing) */
.fish-main {
position: absolute;
inset: 0;
background:
/* Orange tropical fish 16x10 */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='10' viewBox='0 0 16 10' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='2' fill='%23f97316' opacity='0.65'/%3E%3Crect x='0' y='7' width='2' height='2' fill='%23f97316' opacity='0.65'/%3E%3Crect x='2' y='2' width='2' height='6' fill='%23fb923c' opacity='0.65'/%3E%3Crect x='4' y='1' width='8' height='8' fill='%23f97316' opacity='0.6'/%3E%3Crect x='7' y='1' width='2' height='8' fill='%23fef3c7' opacity='0.45'/%3E%3Crect x='5' y='0' width='4' height='1' fill='%23fb923c' opacity='0.45'/%3E%3Crect x='5' y='9' width='4' height='1' fill='%23fb923c' opacity='0.45'/%3E%3Crect x='10' y='3' width='2' height='2' fill='%23000' opacity='0.55'/%3E%3Crect x='11' y='3' width='1' height='1' fill='white' opacity='0.45'/%3E%3Crect x='12' y='5' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 32px 20px,
/* Blue indigo fish 12x8 */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' shape-rendering='crispEdges'%3E%3Crect x='10' y='1' width='2' height='2' fill='%236366f1' opacity='0.55'/%3E%3Crect x='10' y='5' width='2' height='2' fill='%236366f1' opacity='0.55'/%3E%3Crect x='8' y='2' width='2' height='4' fill='%23818cf8' opacity='0.55'/%3E%3Crect x='2' y='1' width='6' height='6' fill='%236366f1' opacity='0.5'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000' opacity='0.45'/%3E%3Crect x='2' y='2' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='4' y='0' width='3' height='1' fill='%23818cf8' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 24px 16px;
animation: fish-swim 22s linear infinite;
}
/* Green fish + Yellow puffer + Red clownfish */
.fish-secondary {
position: absolute;
inset: 0;
background:
/* Green fish 10x6 (left-facing) */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='1' fill='%2322c55e' opacity='0.5'/%3E%3Crect x='0' y='4' width='2' height='1' fill='%2322c55e' opacity='0.5'/%3E%3Crect x='2' y='1' width='1' height='4' fill='%234ade80' opacity='0.5'/%3E%3Crect x='3' y='0' width='5' height='6' fill='%2322c55e' opacity='0.45'/%3E%3Crect x='6' y='1' width='2' height='2' fill='%23000' opacity='0.4'/%3E%3Crect x='7' y='1' width='1' height='1' fill='white' opacity='0.3'/%3E%3Crect x='8' y='3' width='2' height='1' fill='%23000' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 20px 12px,
/* Yellow puffer 14x12 (right-facing) */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='12' viewBox='0 0 14 12' shape-rendering='crispEdges'%3E%3Crect x='0' y='3' width='2' height='2' fill='%23fbbf24' opacity='0.5'/%3E%3Crect x='0' y='7' width='2' height='2' fill='%23fbbf24' opacity='0.5'/%3E%3Crect x='2' y='2' width='2' height='8' fill='%23f59e0b' opacity='0.5'/%3E%3Crect x='4' y='1' width='7' height='10' fill='%23fbbf24' opacity='0.48'/%3E%3Crect x='5' y='0' width='5' height='1' fill='%23f59e0b' opacity='0.35'/%3E%3Crect x='5' y='11' width='5' height='1' fill='%23f59e0b' opacity='0.35'/%3E%3Crect x='6' y='2' width='2' height='8' fill='%23fef3c7' opacity='0.3'/%3E%3Crect x='9' y='4' width='2' height='2' fill='%23000' opacity='0.5'/%3E%3Crect x='10' y='4' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='11' y='6' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3Crect x='4' y='3' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3Crect x='4' y='5' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3Crect x='4' y='7' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 28px 24px,
/* Red clownfish 12x8 (left-facing) */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' shape-rendering='crispEdges'%3E%3Crect x='10' y='1' width='2' height='2' fill='%23ef4444' opacity='0.55'/%3E%3Crect x='10' y='5' width='2' height='2' fill='%23ef4444' opacity='0.55'/%3E%3Crect x='8' y='1' width='2' height='6' fill='%23f87171' opacity='0.55'/%3E%3Crect x='2' y='1' width='6' height='6' fill='%23ef4444' opacity='0.5'/%3E%3Crect x='4' y='1' width='1' height='6' fill='white' opacity='0.4'/%3E%3Crect x='7' y='1' width='1' height='6' fill='white' opacity='0.35'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000' opacity='0.45'/%3E%3Crect x='2' y='2' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='2' y='0' width='4' height='1' fill='%23f87171' opacity='0.4'/%3E%3Crect x='2' y='7' width='4' height='1' fill='%23f87171' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 24px 16px;
animation: fish-swim-secondary 28s linear infinite;
}
/* Event: school of fish - many small fast fish */
.fish-event-school {
position: absolute;
inset: 0;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.5'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.5'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.5'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 16px 10px,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.45'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.35'/%3E%3C/svg%3E") no-repeat / 16px 10px,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.4'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.35'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 16px 10px;
animation: school-dash 8s linear forwards;
}
@keyframes fish-swim {
0% { background-position: -40px 22%, calc(100% + 30px) 52%; }
100% { background-position: calc(100% + 40px) 28%, -30px 48%; }
}
@keyframes fish-swim-secondary {
0% { background-position: calc(100% + 25px) 68%, -35px 38%, calc(100% + 30px) 75%; }
100% { background-position: -25px 62%, calc(100% + 35px) 42%, -30px 70%; }
}
@keyframes school-dash {
0% { background-position: -20px 30%, -30px 35%, -15px 40%; opacity: 0; }
5% { opacity: 0.8; }
90% { opacity: 0.7; }
100% { background-position: calc(100% + 80px) 32%, calc(100% + 60px) 38%, calc(100% + 90px) 36%; opacity: 0; }
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
import { svgDataUri, jellyfishSvg } from '../svgPatterns'
import { useAquaticState } from '../useAquaticState'
const { activeEventModifiers } = useAquaticState()
const bioActive = computed(() => activeEventModifiers.value.has('bioluminescent-flash'))
const jelly1Bg = svgDataUri(jellyfishSvg('#c084fc', '#d8b4fe'))
const jelly2Bg = svgDataUri(jellyfishSvg('#67e8f9', '#a5f3fc'))
</script>
<template>
<div class="jellyfish-drift" :class="{ bioluminescent: bioActive }">
<div
class="jelly jelly-1"
:style="{ backgroundImage: jelly1Bg }"
/>
<div
class="jelly jelly-2"
:style="{ backgroundImage: jelly2Bg }"
/>
</div>
</template>
<style scoped>
.jellyfish-drift {
position: absolute;
inset: 0;
z-index: 8;
pointer-events: none;
}
.jelly {
position: absolute;
width: 32px;
height: 44px;
background-size: contain;
background-repeat: no-repeat;
will-change: transform;
}
.jelly-1 {
left: 20%;
animation:
jelly-vertical 45s linear infinite,
jelly-horizontal 8s ease-in-out infinite alternate,
jelly-pulse 3s ease-in-out infinite;
}
.jelly-2 {
left: 68%;
animation:
jelly-vertical 55s linear infinite -20s,
jelly-horizontal 10s ease-in-out infinite alternate -4s,
jelly-pulse 3.5s ease-in-out infinite -1.5s;
}
.jellyfish-drift.bioluminescent .jelly {
filter: drop-shadow(0 0 6px rgba(103, 232, 249, 0.6));
}
@keyframes jelly-vertical {
0% { top: -50px; }
100% { top: calc(100% + 50px); }
}
@keyframes jelly-horizontal {
from { transform: translateX(-15px); }
to { transform: translateX(15px); }
}
@keyframes jelly-pulse {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.9); }
}
</style>

View File

@@ -0,0 +1,361 @@
<script setup lang="ts">
/**
* OceanScene — The unified static/dynamic background scene.
*
* Combines water gradient, light rays, sea floor, corals, seaweed, and
* decorations into a single cohesive component. Everything here is the
* "world" — the environment that all dynamic overlay layers (fish, bubbles,
* jellyfish, events) float on top of.
*
* Internal z-index stacking (within this component):
* 0 water-gradient
* 1 light-rays
* 2 sea-floor
* 3 decorations (starfish, shells)
* 4 corals
* 5 seaweed
*/
import { ref, computed, onMounted } from 'vue'
import { svgDataUri, coralSvg, seaweedSvg, starfishSvg, shellSvg } from '../svgPatterns'
import { useAquaticState } from '../useAquaticState'
const { season, activeEventModifiers } = useAquaticState()
// ── Event reactivity ──
const bloomActive = computed(() => activeEventModifiers.value.has('seasonal-bloom'))
const currentActive = computed(() => activeEventModifiers.value.has('current-change'))
const bioActive = computed(() => activeEventModifiers.value.has('bioluminescent-flash'))
// ── Season-dependent coral palettes ──
const palette = computed(() => {
switch (season.value) {
case 'spring': return { branching: ['#f472b6', '#fbcfe8'], brain: ['#fbbf24', '#fef3c7'], fan: ['#a78bfa', '#ddd6fe'] }
case 'summer': return { branching: ['#f87171', '#fda4af'], brain: ['#f97316', '#fed7aa'], fan: ['#a855f7', '#d8b4fe'] }
case 'autumn': return { branching: ['#b45309', '#d97706'], brain: ['#92400e', '#b45309'], fan: ['#78716c', '#a8a29e'] }
case 'winter': return { branching: ['#7dd3fc', '#bae6fd'], brain: ['#94a3b8', '#cbd5e1'], fan: ['#a5b4fc', '#c7d2fe'] }
}
})
// ── Corals (reactive to season) ──
const corals = computed(() => [
{ x: 12, bottom: 13, bg: svgDataUri(coralSvg('branching', palette.value.branching[0], palette.value.branching[1])), w: 32, h: 40, delay: '0s' },
{ x: 35, bottom: 14, bg: svgDataUri(coralSvg('brain', palette.value.brain[0], palette.value.brain[1])), w: 24, h: 20, delay: '-3s' },
{ x: 55, bottom: 12, bg: svgDataUri(coralSvg('fan', palette.value.fan[0], palette.value.fan[1])), w: 28, h: 36, delay: '-7s' },
{ x: 75, bottom: 13, bg: svgDataUri(coralSvg('branching', palette.value.branching[1], palette.value.branching[0])), w: 30, h: 38, delay: '-5s' },
{ x: 92, bottom: 14, bg: svgDataUri(coralSvg('brain', palette.value.brain[1], palette.value.brain[0])), w: 22, h: 18, delay: '-9s' },
])
// ── Seaweed (reactive to current-change event) ──
const stalks = [
{ x: 10, height: 52, color: '#16a34a', accent: '#22c55e', dur: 9, delay: '0s' },
{ x: 28, height: 60, color: '#22c55e', accent: '#4ade80', dur: 11, delay: '-2s' },
{ x: 42, height: 48, color: '#15803d', accent: '#16a34a', dur: 8, delay: '-5s' },
{ x: 62, height: 56, color: '#16a34a', accent: '#4ade80', dur: 12, delay: '-3s' },
{ x: 78, height: 44, color: '#22c55e', accent: '#16a34a', dur: 10, delay: '-7s' },
{ x: 90, height: 50, color: '#15803d', accent: '#22c55e', dur: 14, delay: '-1s' },
]
const stalkStyles = computed(() =>
stalks.map(s => ({
left: s.x + '%',
bottom: '10%',
height: s.height + 'px',
width: '10px',
backgroundImage: svgDataUri(seaweedSvg(s.height, s.color, s.accent)),
'--sway-duration': s.dur + 's',
'--sway-amount': currentActive.value ? '6deg' : '3deg',
animationDelay: s.delay,
}))
)
// ── Decorations (randomized once on mount) ──
interface DecoItem {
x: number
bottom: number
bg: string
size: [number, number]
}
const decoItems = ref<DecoItem[]>([])
onMounted(() => {
const r = () => Math.random()
decoItems.value = [
{ x: 8 + r() * 15, bottom: 8 + r() * 5, bg: svgDataUri(starfishSvg('#f97316')), size: [16, 16] },
{ x: 60 + r() * 20, bottom: 9 + r() * 4, bg: svgDataUri(starfishSvg('#fb923c')), size: [14, 14] },
{ x: 25 + r() * 10, bottom: 10 + r() * 3, bg: svgDataUri(shellSvg('#fef3c7')), size: [12, 8] },
{ x: 45 + r() * 10, bottom: 8 + r() * 4, bg: svgDataUri(shellSvg('#fde68a')), size: [10, 7] },
{ x: 82 + r() * 10, bottom: 9 + r() * 3, bg: svgDataUri(shellSvg('#fcd34d')), size: [11, 7] },
]
})
</script>
<template>
<div class="ocean-scene" :class="{ bloom: bloomActive }">
<!-- Layer 0: Water depth gradient -->
<div class="water-gradient" />
<!-- Layer 1: Surface light rays -->
<div class="light-rays" :class="{ 'bio-tint': bioActive }" />
<!-- Layer 2: Sea floor (sand, rocks, pebbles) -->
<div class="sea-floor" />
<!-- Layer 3: Decorations (starfish, shells on the floor) -->
<div class="decorations">
<div
v-for="(d, i) in decoItems"
:key="i"
class="deco-item"
:style="{
left: d.x + '%',
bottom: d.bottom + '%',
width: d.size[0] + 'px',
height: d.size[1] + 'px',
backgroundImage: d.bg,
}"
/>
</div>
<!-- Layer 4: Coral reef -->
<div class="coral-reef">
<div
v-for="(c, i) in corals"
:key="i"
class="coral"
:style="{
left: c.x + '%',
bottom: c.bottom + '%',
width: c.w + 'px',
height: c.h + 'px',
backgroundImage: c.bg,
animationDelay: c.delay,
}"
/>
</div>
<!-- Layer 5: Seaweed field -->
<div class="seaweed-field">
<div
v-for="(style, i) in stalkStyles"
:key="i"
class="seaweed-stalk"
:style="style"
/>
</div>
</div>
</template>
<style scoped>
/* ============================================================================
OCEAN SCENE — Unified background world
All scenery layers live here, working together as one cohesive environment.
The parent .aquatic-bg provides .tod-* and .season-* classes.
============================================================================ */
.ocean-scene {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.ocean-scene > div {
position: absolute;
inset: 0;
pointer-events: none;
}
/* ── Layer 0: Water depth gradient ──────────────────────────────────── */
.water-gradient {
z-index: 0;
transition: background 2s ease;
background: linear-gradient(
180deg,
rgba(0, 6, 22, 0.97) 0%,
rgba(0, 12, 35, 0.95) 10%,
rgba(0, 20, 52, 0.93) 22%,
rgba(0, 30, 65, 0.90) 35%,
rgba(2, 42, 75, 0.87) 48%,
rgba(4, 52, 78, 0.84) 60%,
rgba(6, 58, 72, 0.82) 72%,
rgba(10, 55, 58, 0.80) 82%,
rgba(18, 50, 45, 0.78) 90%,
rgba(28, 48, 35, 0.76) 100%
);
}
:global(.tod-twilight) .water-gradient {
background: linear-gradient(
180deg,
rgba(15, 5, 30, 0.96) 0%,
rgba(20, 10, 45, 0.94) 10%,
rgba(18, 15, 55, 0.92) 22%,
rgba(12, 25, 65, 0.89) 35%,
rgba(8, 38, 72, 0.86) 48%,
rgba(6, 48, 75, 0.83) 60%,
rgba(8, 52, 68, 0.80) 72%,
rgba(12, 50, 55, 0.78) 82%,
rgba(20, 48, 42, 0.76) 90%,
rgba(28, 45, 32, 0.74) 100%
);
}
:global(.tod-day) .water-gradient {
background: linear-gradient(
180deg,
rgba(2, 18, 40, 0.92) 0%,
rgba(4, 28, 55, 0.90) 10%,
rgba(6, 38, 68, 0.88) 22%,
rgba(8, 48, 78, 0.85) 35%,
rgba(10, 58, 85, 0.82) 48%,
rgba(12, 65, 82, 0.79) 60%,
rgba(14, 68, 78, 0.76) 72%,
rgba(16, 62, 65, 0.74) 82%,
rgba(22, 58, 52, 0.72) 90%,
rgba(30, 55, 40, 0.70) 100%
);
}
/* Depth zone shifts */
:global(.depth-surface) .water-gradient {
filter: brightness(1.15);
}
:global(.depth-deep) .water-gradient {
filter: brightness(0.8);
}
/* ── Layer 1: Light rays ────────────────────────────────────────────── */
.light-rays {
z-index: 1;
background:
repeating-linear-gradient(
-20deg,
transparent 0px,
transparent 80px,
rgba(103, 232, 249, 0.02) 80px,
rgba(103, 232, 249, 0.025) 84px,
transparent 84px,
transparent 200px
),
repeating-linear-gradient(
-35deg,
transparent 0px,
transparent 120px,
rgba(56, 189, 248, 0.015) 120px,
rgba(56, 189, 248, 0.02) 123px,
transparent 123px,
transparent 300px
);
animation: light-pulse 10s ease-in-out infinite alternate;
transition: opacity 2s ease;
}
:global(.tod-night) .light-rays { opacity: 0.7; }
:global(.tod-day) .light-rays { opacity: 1; }
/* Bioluminescent event: green light tint */
.light-rays.bio-tint {
background:
repeating-linear-gradient(
-20deg,
transparent 0px,
transparent 80px,
rgba(74, 222, 128, 0.03) 80px,
rgba(74, 222, 128, 0.04) 84px,
transparent 84px,
transparent 200px
),
repeating-linear-gradient(
-35deg,
transparent 0px,
transparent 120px,
rgba(34, 197, 94, 0.02) 120px,
rgba(34, 197, 94, 0.03) 123px,
transparent 123px,
transparent 300px
);
}
@keyframes light-pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.7; }
}
/* ── Layer 2: Sea floor ─────────────────────────────────────────────── */
.sea-floor {
z-index: 2;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='480' height='160' viewBox='0 0 480 160' shape-rendering='crispEdges'%3E%3Crect x='0' y='120' width='480' height='40' fill='%23c2a060' opacity='0.55'/%3E%3Crect x='0' y='116' width='480' height='6' fill='%23d4b878' opacity='0.45'/%3E%3Crect x='0' y='114' width='480' height='4' fill='%23b89850' opacity='0.25'/%3E%3Crect x='15' y='124' width='30' height='2' fill='%23a89048' opacity='0.25'/%3E%3Crect x='80' y='128' width='20' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='140' y='126' width='35' height='2' fill='%23a89048' opacity='0.25'/%3E%3Crect x='220' y='130' width='25' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='290' y='124' width='30' height='2' fill='%23a89048' opacity='0.22'/%3E%3Crect x='360' y='128' width='22' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='420' y='126' width='28' height='2' fill='%23a89048' opacity='0.22'/%3E%3Crect x='20' y='106' width='16' height='10' fill='%23475569' opacity='0.5'/%3E%3Crect x='22' y='104' width='12' height='4' fill='%2364748b' opacity='0.4'/%3E%3Crect x='24' y='102' width='6' height='4' fill='%23718096' opacity='0.3'/%3E%3Crect x='340' y='108' width='14' height='8' fill='%23475569' opacity='0.45'/%3E%3Crect x='342' y='106' width='10' height='4' fill='%2364748b' opacity='0.35'/%3E%3Crect x='344' y='104' width='4' height='4' fill='%23718096' opacity='0.25'/%3E%3Crect x='200' y='110' width='10' height='6' fill='%23475569' opacity='0.4'/%3E%3Crect x='202' y='108' width='6' height='4' fill='%2364748b' opacity='0.3'/%3E%3Crect x='40' y='118' width='2' height='2' fill='%2364748b' opacity='0.25'/%3E%3Crect x='75' y='120' width='2' height='2' fill='%2364748b' opacity='0.2'/%3E%3Crect x='110' y='118' width='2' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='150' y='120' width='2' height='2' fill='%2364748b' opacity='0.2'/%3E%3Crect x='215' y='118' width='2' height='2' fill='%23475569' opacity='0.22'/%3E%3Crect x='265' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3Crect x='310' y='118' width='2' height='2' fill='%23475569' opacity='0.22'/%3E%3Crect x='355' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3Crect x='385' y='118' width='2' height='2' fill='%23475569' opacity='0.2'/%3E%3Crect x='450' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3C/svg%3E") repeat-x bottom center / 480px 160px;
}
/* ── Layer 3: Decorations (starfish, shells) ────────────────────────── */
.decorations { z-index: 3; }
.deco-item {
position: absolute;
background-size: contain;
background-repeat: no-repeat;
}
/* ── Layer 4: Coral reef ────────────────────────────────────────────── */
.coral-reef {
z-index: 4;
transition: filter 3s ease;
}
/* Seasonal bloom: corals glow and brighten */
.ocean-scene.bloom .coral-reef {
filter: brightness(1.3) saturate(1.2);
}
/* Bloom also intensifies light rays */
.ocean-scene.bloom .light-rays {
opacity: 1 !important;
}
.coral {
position: absolute;
background-size: contain;
background-repeat: no-repeat;
transform-origin: bottom center;
animation: coral-sway 12s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes coral-sway {
from { transform: rotate(-1deg); }
to { transform: rotate(1deg); }
}
/* ── Layer 5: Seaweed field ─────────────────────────────────────────── */
.seaweed-field { z-index: 5; }
.seaweed-stalk {
position: absolute;
background-size: contain;
background-repeat: no-repeat;
background-position: bottom center;
transform-origin: bottom center;
animation: seaweed-sway var(--sway-duration, 10s) ease-in-out infinite alternate;
will-change: transform;
}
/* Current-change event also speeds up coral sway */
:global(.evt-current-change) .coral {
animation-duration: 6s;
}
@keyframes seaweed-sway {
from { transform: skewX(calc(var(--sway-amount, 3deg) * -1)); }
to { transform: skewX(var(--sway-amount, 3deg)); }
}
</style>

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

@@ -0,0 +1,10 @@
// The unified background scene (gradient + light + floor + corals + seaweed + decorations)
export { default as OceanScene } from './OceanScene.vue'
// Independent dynamic overlay layers
export { default as BubbleStream } from './BubbleStream.vue'
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

@@ -0,0 +1,139 @@
/** Encode an SVG string to a CSS-usable data URI */
export function svgDataUri(svg: string): string {
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
/** Generate a pixel art fish SVG */
export function fishSvg(opts: {
w: number
h: number
body: string
accent: string
eye?: string
facing?: 'left' | 'right'
}): string {
const { w, h, body, accent, facing = 'right' } = opts
const eye = opts.eye ?? '#000'
// Build a generic pixel fish: body rect, tail, eye
const hw = Math.floor(w / 2)
const hh = Math.floor(h / 2)
const tailW = Math.floor(w * 0.2)
const bodyW = w - tailW
const eyeX = facing === 'right' ? bodyW - 3 : tailW + 1
const tailX = facing === 'right' ? 0 : bodyW
return `<svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}' shape-rendering='crispEdges'>`
// Tail
+ `<rect x='${tailX}' y='1' width='${tailW}' height='${Math.floor(h * 0.3)}' fill='${accent}' opacity='0.6'/>`
+ `<rect x='${tailX}' y='${h - Math.floor(h * 0.3) - 1}' width='${tailW}' height='${Math.floor(h * 0.3)}' fill='${accent}' opacity='0.6'/>`
// Body
+ `<rect x='${facing === 'right' ? tailW : 0}' y='1' width='${bodyW}' height='${h - 2}' fill='${body}' opacity='0.55'/>`
// Stripe
+ `<rect x='${hw - 1}' y='1' width='2' height='${h - 2}' fill='${accent}' opacity='0.35'/>`
// Eye
+ `<rect x='${eyeX}' y='${hh - 1}' width='2' height='2' fill='${eye}' opacity='0.5'/>`
+ `<rect x='${eyeX + (facing === 'right' ? 1 : 0)}' y='${hh - 1}' width='1' height='1' fill='white' opacity='0.35'/>`
+ `</svg>`
}
/** Generate a coral SVG of a given type */
export function coralSvg(type: 'branching' | 'brain' | 'fan', color: string, accent: string): string {
if (type === 'branching') {
return `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='20' viewBox='0 0 16 20' shape-rendering='crispEdges'>`
+ `<rect x='7' y='8' width='2' height='12' fill='${color}' opacity='0.55'/>`
+ `<rect x='5' y='6' width='2' height='8' fill='${color}' opacity='0.5'/>`
+ `<rect x='9' y='5' width='2' height='9' fill='${color}' opacity='0.5'/>`
+ `<rect x='3' y='3' width='2' height='6' fill='${accent}' opacity='0.45'/>`
+ `<rect x='11' y='2' width='2' height='7' fill='${accent}' opacity='0.45'/>`
+ `<rect x='5' y='4' width='2' height='2' fill='${accent}' opacity='0.4'/>`
+ `<rect x='9' y='3' width='2' height='2' fill='${accent}' opacity='0.4'/>`
+ `<rect x='1' y='1' width='2' height='4' fill='${accent}' opacity='0.35'/>`
+ `<rect x='13' y='0' width='2' height='5' fill='${accent}' opacity='0.35'/>`
+ `</svg>`
}
if (type === 'brain') {
return `<svg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10' shape-rendering='crispEdges'>`
+ `<rect x='2' y='3' width='8' height='6' rx='0' fill='${color}' opacity='0.5'/>`
+ `<rect x='3' y='2' width='6' height='2' fill='${color}' opacity='0.45'/>`
+ `<rect x='4' y='1' width='4' height='2' fill='${accent}' opacity='0.4'/>`
+ `<rect x='3' y='4' width='2' height='2' fill='${accent}' opacity='0.3'/>`
+ `<rect x='7' y='5' width='2' height='2' fill='${accent}' opacity='0.3'/>`
+ `<rect x='5' y='7' width='2' height='2' fill='${accent}' opacity='0.25'/>`
+ `</svg>`
}
// fan
return `<svg xmlns='http://www.w3.org/2000/svg' width='14' height='18' viewBox='0 0 14 18' shape-rendering='crispEdges'>`
+ `<rect x='6' y='12' width='2' height='6' fill='${color}' opacity='0.5'/>`
+ `<rect x='4' y='8' width='6' height='5' fill='${color}' opacity='0.45'/>`
+ `<rect x='3' y='5' width='8' height='4' fill='${accent}' opacity='0.4'/>`
+ `<rect x='2' y='2' width='10' height='4' fill='${accent}' opacity='0.35'/>`
+ `<rect x='4' y='0' width='6' height='3' fill='${accent}' opacity='0.3'/>`
+ `<rect x='5' y='6' width='2' height='2' fill='${color}' opacity='0.25'/>`
+ `<rect x='8' y='4' width='2' height='2' fill='${color}' opacity='0.25'/>`
+ `</svg>`
}
/** Generate a seaweed stalk SVG */
export function seaweedSvg(height: number, color: string, accent: string): string {
const segments: string[] = []
for (let y = 0; y < height; y += 4) {
const xOff = (y / 4) % 2 === 0 ? 0 : 1
const c = y % 8 === 0 ? color : accent
const op = 0.3 + (y / height) * 0.25
segments.push(`<rect x='${1 + xOff}' y='${y}' width='2' height='4' fill='${c}' opacity='${op.toFixed(2)}'/>`)
}
// Leaf accents every 12px
for (let y = 6; y < height - 4; y += 12) {
const side = (y / 12) % 2 === 0 ? 0 : 3
segments.push(`<rect x='${side}' y='${y}' width='2' height='3' fill='${accent}' opacity='0.3'/>`)
}
return `<svg xmlns='http://www.w3.org/2000/svg' width='5' height='${height}' viewBox='0 0 5 ${height}' shape-rendering='crispEdges'>`
+ segments.join('')
+ `</svg>`
}
/** Generate a jellyfish SVG */
export function jellyfishSvg(bell: string, tentacle: string): string {
return `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='22' viewBox='0 0 16 22' shape-rendering='crispEdges'>`
// Bell
+ `<rect x='4' y='0' width='8' height='2' fill='${bell}' opacity='0.4'/>`
+ `<rect x='2' y='2' width='12' height='2' fill='${bell}' opacity='0.45'/>`
+ `<rect x='1' y='4' width='14' height='4' fill='${bell}' opacity='0.5'/>`
+ `<rect x='2' y='8' width='12' height='2' fill='${bell}' opacity='0.45'/>`
// Inner glow
+ `<rect x='5' y='4' width='6' height='3' fill='white' opacity='0.15'/>`
// Tentacles
+ `<rect x='3' y='10' width='1' height='8' fill='${tentacle}' opacity='0.35'/>`
+ `<rect x='6' y='10' width='1' height='10' fill='${tentacle}' opacity='0.3'/>`
+ `<rect x='9' y='10' width='1' height='9' fill='${tentacle}' opacity='0.32'/>`
+ `<rect x='12' y='10' width='1' height='7' fill='${tentacle}' opacity='0.28'/>`
// Tentacle wiggles
+ `<rect x='2' y='14' width='1' height='2' fill='${tentacle}' opacity='0.25'/>`
+ `<rect x='7' y='16' width='1' height='2' fill='${tentacle}' opacity='0.22'/>`
+ `<rect x='10' y='15' width='1' height='2' fill='${tentacle}' opacity='0.22'/>`
+ `</svg>`
}
/** Generate a starfish SVG */
export function starfishSvg(color: string): string {
return `<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'>`
+ `<rect x='3' y='0' width='2' height='3' fill='${color}' opacity='0.45'/>`
+ `<rect x='0' y='3' width='3' height='2' fill='${color}' opacity='0.45'/>`
+ `<rect x='5' y='3' width='3' height='2' fill='${color}' opacity='0.45'/>`
+ `<rect x='2' y='2' width='4' height='4' fill='${color}' opacity='0.5'/>`
+ `<rect x='1' y='5' width='2' height='2' fill='${color}' opacity='0.4'/>`
+ `<rect x='5' y='5' width='2' height='2' fill='${color}' opacity='0.4'/>`
+ `<rect x='3' y='3' width='2' height='2' fill='white' opacity='0.15'/>`
+ `</svg>`
}
/** Generate a small shell SVG */
export function shellSvg(color: string): string {
return `<svg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4' shape-rendering='crispEdges'>`
+ `<rect x='1' y='2' width='4' height='2' fill='${color}' opacity='0.4'/>`
+ `<rect x='2' y='1' width='2' height='1' fill='${color}' opacity='0.35'/>`
+ `<rect x='0' y='3' width='1' height='1' fill='${color}' opacity='0.3'/>`
+ `<rect x='5' y='3' width='1' height='1' fill='${color}' opacity='0.3'/>`
+ `<rect x='2' y='2' width='1' height='1' fill='white' opacity='0.15'/>`
+ `</svg>`
}

View File

@@ -0,0 +1,24 @@
export type EventFrequency = 'minutes' | 'hours' | 'days' | 'months'
export type TimeOfDay = 'day' | 'twilight' | 'night'
export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
export type DepthZone = 'surface' | 'midwater' | 'deep'
export interface AquaticEvent {
id: string
name: string
frequency: EventFrequency
/** Min interval in milliseconds */
minInterval: number
/** Max interval in milliseconds */
maxInterval: number
/** Duration of the event in milliseconds */
duration: number
/** CSS class applied to EventOverlay when active */
cssClass: string
}
export interface ActiveEvent {
event: AquaticEvent
startedAt: number
endsAt: number
}

View File

@@ -0,0 +1,176 @@
import { ref, readonly } from 'vue'
import { useAquaticState } from './useAquaticState'
import type { AquaticEvent, ActiveEvent, EventFrequency, TimeOfDay, Season, DepthZone } from './types'
// ── Event catalog ──
const MINUTE_EVENTS: AquaticEvent[] = [
{ id: 'school-of-fish', name: 'School of Fish', frequency: 'minutes', minInterval: 120_000, maxInterval: 480_000, duration: 15_000, cssClass: 'evt-school-of-fish' },
{ id: 'bubble-burst', name: 'Bubble Burst', frequency: 'minutes', minInterval: 120_000, maxInterval: 480_000, duration: 8_000, cssClass: 'evt-bubble-burst' },
{ id: 'bioluminescent-flash', name: 'Bioluminescent Flash', frequency: 'minutes', minInterval: 180_000, maxInterval: 480_000, duration: 10_000, cssClass: 'evt-bioluminescent-flash' },
{ id: 'plankton-drift', name: 'Plankton Drift', frequency: 'minutes', minInterval: 150_000, maxInterval: 420_000, duration: 12_000, cssClass: 'evt-plankton-drift' },
{ id: 'fish-chase', name: 'Fish Chase', frequency: 'minutes', minInterval: 120_000, maxInterval: 360_000, duration: 10_000, cssClass: 'evt-fish-chase' },
]
const HOUR_EVENTS: AquaticEvent[] = [
{ id: 'whale-shadow', name: 'Whale Shadow', frequency: 'hours', minInterval: 3_600_000, maxInterval: 14_400_000, duration: 20_000, cssClass: 'evt-whale-shadow' },
{ id: 'current-change', name: 'Current Change', frequency: 'hours', minInterval: 3_600_000, maxInterval: 10_800_000, duration: 60_000, cssClass: 'evt-current-change' },
{ id: 'color-shift', name: 'Color Shift', frequency: 'hours', minInterval: 3_600_000, maxInterval: 14_400_000, duration: 30_000, cssClass: 'evt-color-shift' },
{ id: 'turtle-crossing', name: 'Turtle Crossing', frequency: 'hours', minInterval: 7_200_000, maxInterval: 14_400_000, duration: 25_000, cssClass: 'evt-turtle-crossing' },
{ id: 'manta-ray', name: 'Manta Ray', frequency: 'hours', minInterval: 5_400_000, maxInterval: 14_400_000, duration: 18_000, cssClass: 'evt-manta-ray' },
]
const DAY_EVENTS: AquaticEvent[] = [
{ id: 'day-night-shift', name: 'Day/Night Shift', frequency: 'days', minInterval: 86_400_000, maxInterval: 259_200_000, duration: 120_000, cssClass: 'evt-day-night-shift' },
{ id: 'seasonal-bloom', name: 'Seasonal Bloom', frequency: 'days', minInterval: 86_400_000, maxInterval: 259_200_000, duration: 180_000, cssClass: 'evt-seasonal-bloom' },
{ id: 'depth-change', name: 'Depth Change', frequency: 'days', minInterval: 86_400_000, maxInterval: 172_800_000, duration: 90_000, cssClass: 'evt-depth-change' },
{ id: 'kelp-forest', name: 'Kelp Forest', frequency: 'days', minInterval: 129_600_000, maxInterval: 259_200_000, duration: 150_000, cssClass: 'evt-kelp-forest' },
]
const MONTH_EVENTS: AquaticEvent[] = [
{ id: 'aurora-underwater', name: 'Aurora Underwater', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 300_000, cssClass: 'evt-aurora-underwater' },
{ id: 'mythical-creature', name: 'Mythical Creature', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 30_000, cssClass: 'evt-mythical-creature' },
{ id: 'volcanic-vent', name: 'Volcanic Vent', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 7_776_000_000, duration: 120_000, cssClass: 'evt-volcanic-vent' },
{ id: 'crystal-formation', name: 'Crystal Formation', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 240_000, cssClass: 'evt-crystal-formation' },
]
const STORAGE_KEY = 'aquatic-event-timestamps'
// ── Helpers ──
function pickRandom<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}
function randomBetween(min: number, max: number): number {
return min + Math.random() * (max - min)
}
function loadTimestamps(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
} catch {
return {}
}
}
function saveTimestamp(frequency: EventFrequency) {
const ts = loadTimestamps()
ts[frequency] = Date.now()
localStorage.setItem(STORAGE_KEY, JSON.stringify(ts))
}
// ── Composable ──
export function useAquaticEvents() {
const { setEventModifier, clearEventModifier, setTimeOfDay, setSeason, setDepthZone } = useAquaticState()
const activeEvents = ref<ActiveEvent[]>([])
const timerIds: number[] = []
const TIERS: { frequency: EventFrequency; events: AquaticEvent[] }[] = [
{ frequency: 'minutes', events: MINUTE_EVENTS },
{ frequency: 'hours', events: HOUR_EVENTS },
{ frequency: 'days', events: DAY_EVENTS },
{ frequency: 'months', events: MONTH_EVENTS },
]
function start() {
const timestamps = loadTimestamps()
const now = Date.now()
for (const tier of TIERS) {
const lastFired = timestamps[tier.frequency] || 0
const elapsed = now - lastFired
// Pick a representative event to check interval
const sample = tier.events[0]
const minWait = sample.minInterval
if (elapsed >= minWait) {
// Enough time passed — trigger one soon (5-30s from now)
const initialDelay = randomBetween(5_000, 30_000)
const id = window.setTimeout(() => {
triggerRandomFromTier(tier.frequency, tier.events)
scheduleTier(tier.frequency, tier.events)
}, initialDelay)
timerIds.push(id)
} else {
// Wait for remaining time, then start normal cycle
const remaining = minWait - elapsed
const id = window.setTimeout(() => {
scheduleTier(tier.frequency, tier.events)
}, remaining)
timerIds.push(id)
}
}
}
function stop() {
timerIds.forEach(id => clearTimeout(id))
timerIds.length = 0
// Clean up active events
for (const ae of activeEvents.value) {
clearEventModifier(ae.event.id)
}
activeEvents.value = []
}
function scheduleTier(frequency: EventFrequency, events: AquaticEvent[]) {
const event = pickRandom(events)
const delay = randomBetween(event.minInterval, event.maxInterval)
const id = window.setTimeout(() => {
triggerRandomFromTier(frequency, events)
scheduleTier(frequency, events)
}, delay)
timerIds.push(id)
}
function triggerRandomFromTier(frequency: EventFrequency, events: AquaticEvent[]) {
// Filter out already-active events
const available = events.filter(e =>
!activeEvents.value.some(ae => ae.event.id === e.id)
)
if (available.length === 0) return
const event = pickRandom(available)
triggerEvent(event, frequency)
}
function triggerEvent(event: AquaticEvent, frequency: EventFrequency) {
const now = Date.now()
const active: ActiveEvent = {
event,
startedAt: now,
endsAt: now + event.duration,
}
activeEvents.value = [...activeEvents.value, active]
setEventModifier(event.id, event.cssClass)
saveTimestamp(frequency)
// Special state mutations for certain events
if (event.id === 'day-night-shift') {
const options: TimeOfDay[] = ['day', 'twilight', 'night']
setTimeOfDay(pickRandom(options))
}
if (event.id === 'seasonal-bloom') {
const options: Season[] = ['spring', 'summer', 'autumn', 'winter']
setSeason(pickRandom(options))
}
if (event.id === 'depth-change') {
const options: DepthZone[] = ['surface', 'midwater', 'deep']
setDepthZone(pickRandom(options))
}
// Schedule end
const endId = window.setTimeout(() => {
activeEvents.value = activeEvents.value.filter(ae => ae !== active)
clearEventModifier(event.id)
}, event.duration)
timerIds.push(endId)
}
return {
activeEvents: readonly(activeEvents),
start,
stop,
}
}

View File

@@ -0,0 +1,54 @@
import { ref, readonly } from 'vue'
import type { TimeOfDay, Season, DepthZone } from './types'
// ── Module-level singleton refs ──
const depthZone = ref<DepthZone>('midwater')
const timeOfDay = ref<TimeOfDay>('night')
const season = ref<Season>(getCurrentSeason())
const activeEventModifiers = ref<Map<string, string>>(new Map())
function getCurrentSeason(): Season {
const month = new Date().getMonth()
if (month >= 2 && month <= 4) return 'spring'
if (month >= 5 && month <= 7) return 'summer'
if (month >= 8 && month <= 10) return 'autumn'
return 'winter'
}
export function useAquaticState() {
function setDepthZone(zone: DepthZone) {
depthZone.value = zone
}
function setTimeOfDay(tod: TimeOfDay) {
timeOfDay.value = tod
}
function setSeason(s: Season) {
season.value = s
}
function setEventModifier(eventId: string, cssClass: string) {
const next = new Map(activeEventModifiers.value)
next.set(eventId, cssClass)
activeEventModifiers.value = next
}
function clearEventModifier(eventId: string) {
const next = new Map(activeEventModifiers.value)
next.delete(eventId)
activeEventModifiers.value = next
}
return {
depthZone: readonly(depthZone),
timeOfDay: readonly(timeOfDay),
season: readonly(season),
activeEventModifiers: readonly(activeEventModifiers),
setDepthZone,
setTimeOfDay,
setSeason,
setEventModifier,
clearEventModifier,
}
}

View File

@@ -0,0 +1,21 @@
export { default as SessionSelector } from './SessionSelector.vue'
export { default as RawJsonViewer } from './RawJsonViewer.vue'
export { default as ChatContainer } from './ChatContainer.vue'
export { default as UserMessageBubble } from './UserMessageBubble.vue'
export { default as AssistantMessageBubble } from './AssistantMessageBubble.vue'
export { default as ThinkingBlock } from './ThinkingBlock.vue'
export { default as ToolCallBlock } from './ToolCallBlock.vue'
export { default as ToolResultBlock } from './ToolResultBlock.vue'
export { default as ProgressEvent } from './ProgressEvent.vue'
export { default as SystemMessage } from './SystemMessage.vue'
export { default as TurnEndDivider } from './TurnEndDivider.vue'
export { default as UserInput } from './UserInput.vue'
export { default as PermissionApproval } from './PermissionApproval.vue'
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 { default as NewSessionModal } from './NewSessionModal.vue'
export { AquaticBackground } from './aquaticBackground'
export { SyncEnginePanel } from './sync-engine'

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSessionState, type PtySessionState } from '@/stores/session-state'
const sessionState = useSessionState()
const ptySessions = computed(() => sessionState.ptySessionList)
const STATUS_COLORS: Record<string, string> = {
idle: '#6b7280',
thinking: '#a78bfa',
reading: '#60a5fa',
writing: '#fbbf24',
toolUse: '#fb923c',
permissionRequest: '#f87171',
interrupted: '#ef4444',
error: '#ef4444',
sessionStart: '#4ade80',
sessionEnd: '#6b7280',
}
function elapsed(ts: number): string {
if (!ts) return '-'
const diff = Date.now() - ts
if (diff < 1000) return '<1s'
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
}
function truncate(s: string | null | undefined, max = 24): string {
if (!s) return '-'
return s.length > max ? s.slice(0, max) + '...' : s
}
</script>
<template>
<div class="agent-states">
<div v-if="!ptySessions.length" class="empty">No PTY sessions</div>
<div v-for="ps in ptySessions" :key="ps.ptySessionId" class="agent-card">
<div class="agent-header">
<span class="agent-name pty-id">{{ ps.ptySessionId }}</span>
<span class="status-badge" :style="{ background: STATUS_COLORS[ps.status] || '#6b7280' }">
{{ ps.status }}
</span>
</div>
<div class="row">
<span class="label">Agent</span>
<span class="value">{{ ps.agent }}</span>
</div>
<div v-if="ps.currentTool" class="row">
<span class="label">Tool</span>
<span class="value tool-name">{{ ps.currentTool.name }}</span>
<span class="value dim">{{ elapsed(ps.currentTool.startedAt) }}</span>
</div>
<div v-if="ps.transcriptSessionId" class="row">
<span class="label">Session</span>
<span class="value mono">{{ truncate(ps.transcriptSessionId, 20) }}</span>
</div>
<div v-if="ps.model" class="row">
<span class="label">Model</span>
<span class="value">{{ ps.model }}</span>
</div>
<div v-if="ps.permissionMode" class="row">
<span class="label">Mode</span>
<span class="value">{{ ps.permissionMode }}</span>
</div>
<div class="row">
<span class="label">Activity</span>
<span class="value">{{ elapsed(ps.lastActivity) }} ago</span>
</div>
<div v-if="ps.lastError" class="row error-row">
<span class="label">Error</span>
<span class="value error-text">{{ ps.lastError.tool }}: {{ truncate(ps.lastError.message, 40) }}</span>
</div>
<div v-if="ps.pendingApprovals.length" class="row">
<span class="label">Approvals</span>
<span class="value warning-text">{{ ps.pendingApprovals.length }} pending</span>
</div>
<div class="flags">
<span v-if="ps.sessionActive" class="flag active">session</span>
<span v-if="ps.agentResponding" class="flag responding">responding</span>
<span v-if="ps.subagentActive" class="flag subagent">subagent</span>
<span v-if="ps.compacting" class="flag compacting">compacting</span>
</div>
</div>
</div>
</template>
<style scoped>
.agent-states {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.empty {
color: #6b7280;
font-size: 12px;
padding: 8px;
}
.agent-card {
flex: 1;
min-width: 220px;
max-width: 340px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(34, 211, 238, 0.12);
border-radius: 4px;
padding: 8px 10px;
font-size: 11px;
line-height: 1.5;
}
.agent-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.agent-name {
font-weight: 700;
font-size: 12px;
color: #e2e8f0;
}
.status-badge {
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
color: #fff;
}
.row {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 0;
}
.label {
color: #64748b;
min-width: 55px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.value {
color: #cbd5e1;
}
.value.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
}
.value.dim {
color: #64748b;
font-size: 10px;
}
.tool-name {
color: #fbbf24;
font-weight: 600;
}
.error-row {
background: rgba(239, 68, 68, 0.08);
border-radius: 2px;
padding: 2px 4px;
margin: 1px -4px;
}
.error-text {
color: #f87171;
font-size: 10px;
}
.warning-text {
color: #fbbf24;
font-weight: 600;
}
.flags {
display: flex;
gap: 4px;
margin-top: 4px;
flex-wrap: wrap;
}
.flag {
padding: 0 4px;
border-radius: 2px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.flag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
.flag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
.flag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
.flag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.pty-id {
font-size: 10px;
font-family: 'Courier New', monospace;
color: #67e8f9;
}
</style>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSessionState, type HookHistoryEntry } from '@/stores/session-state'
const sessionState = useSessionState()
const EVENT_COLORS: Record<string, string> = {
SessionStart: '#60a5fa',
UserPromptSubmit: '#a78bfa',
PreToolUse: '#fbbf24',
PermissionRequest: '#fb923c',
PostToolUse: '#4ade80',
PostToolUseFailure: '#f87171',
Notification: '#38bdf8',
SubagentStart: '#c084fc',
SubagentStop: '#a855f7',
Stop: '#22d3ee',
TeammateIdle: '#94a3b8',
TaskCompleted: '#34d399',
ConfigChange: '#e879f9',
PreCompact: '#f59e0b',
SessionEnd: '#6b7280',
}
const AGENT_COLORS: Record<string, string> = {
ejecutor: '#60a5fa',
nucleo000: '#4ade80',
claude: '#a78bfa',
}
interface TimelineEntry extends HookHistoryEntry {
agent: string
ptySessionId: string
}
// Filters
const ptyFilter = ref<string>('all')
const eventFilter = ref<string>('all')
const MAX_DISPLAY = 200
// Merge all per-PTY sessions into one sorted timeline
const allEntries = computed<TimelineEntry[]>(() => {
const entries: TimelineEntry[] = []
const registry = sessionState.terminalRegistry
for (const [ptyId, ptyState] of Object.entries(sessionState.ptySessions)) {
const regEntry = registry.find(r => r.ephemeralSessionId === ptyId)
const agent = regEntry?.agent || ptyState.agent || 'unknown'
for (const h of ptyState.hookHistory) {
entries.push({ ...h, agent, ptySessionId: ptyId })
}
}
entries.sort((a, b) => b.timestamp - a.timestamp)
return entries
})
// Get unique PTY session IDs with their agent for display
const ptyOptions = computed(() => {
const map = new Map<string, string>() // ptyId → agent
for (const e of allEntries.value) map.set(e.ptySessionId, e.agent)
return Array.from(map.entries()).map(([ptyId, agent]) => ({ ptyId, agent }))
})
// Get unique event types for filter
const eventTypes = computed(() => {
const set = new Set<string>()
for (const e of allEntries.value) set.add(e.event)
return Array.from(set).sort()
})
// Apply filters
const filtered = computed(() => {
let list = allEntries.value
if (ptyFilter.value !== 'all') {
list = list.filter(e => e.ptySessionId === ptyFilter.value)
}
if (eventFilter.value !== 'all') {
list = list.filter(e => e.event === eventFilter.value)
}
return list
})
const displayed = computed(() => filtered.value.slice(0, MAX_DISPLAY))
function formatTime(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
</script>
<template>
<div class="hook-timeline">
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<button
:class="['filter-btn', { active: ptyFilter === 'all' }]"
@click="ptyFilter = 'all'"
>All</button>
<button
v-for="p in ptyOptions"
:key="p.ptyId"
:class="['filter-btn', { active: ptyFilter === p.ptyId }]"
:style="{ '--accent': AGENT_COLORS[p.agent] || '#64748b' }"
@click="ptyFilter = p.ptyId"
:title="p.ptyId"
>{{ p.agent }}:{{ p.ptyId.slice(-6) }}</button>
</div>
<select v-model="eventFilter" class="event-select">
<option value="all">All events</option>
<option v-for="ev in eventTypes" :key="ev" :value="ev">{{ ev }}</option>
</select>
<span class="count">{{ displayed.length }}<span v-if="filtered.length > MAX_DISPLAY"> / {{ filtered.length }}</span></span>
</div>
<!-- Timeline -->
<div class="timeline-list">
<div v-if="!displayed.length" class="empty">No events</div>
<div v-for="(entry, i) in displayed" :key="i" class="timeline-entry">
<span class="time">{{ formatTime(entry.timestamp) }}</span>
<span class="agent-badge" :style="{ background: AGENT_COLORS[entry.agent] || '#64748b' }" :title="entry.ptySessionId">{{ entry.agent }}:{{ entry.ptySessionId.slice(-6) }}</span>
<span class="event-name" :style="{ color: EVENT_COLORS[entry.event] || '#94a3b8' }">{{ entry.event }}</span>
<span v-if="entry.detail" class="detail">{{ entry.detail }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.hook-timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 2px;
}
.filter-btn {
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
background: transparent;
color: #94a3b8;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.filter-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
}
.filter-btn.active {
background: var(--accent, #0ea5e9);
color: #fff;
border-color: var(--accent, #0ea5e9);
}
.event-select {
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
background: rgba(0, 0, 0, 0.3);
color: #cbd5e1;
font-size: 10px;
font-family: inherit;
cursor: pointer;
}
.count {
color: #64748b;
font-size: 10px;
margin-left: auto;
}
.timeline-list {
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.empty {
color: #6b7280;
font-size: 11px;
padding: 8px;
}
.timeline-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border-radius: 2px;
font-size: 11px;
transition: background 0.1s;
}
.timeline-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.time {
color: #475569;
font-family: 'Courier New', monospace;
font-size: 10px;
min-width: 85px;
flex-shrink: 0;
}
.agent-badge {
padding: 0 4px;
border-radius: 2px;
font-size: 9px;
font-weight: 700;
color: #fff;
min-width: 85px;
text-align: center;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-name {
font-weight: 600;
font-size: 11px;
min-width: 120px;
flex-shrink: 0;
}
.detail {
color: #64748b;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useWsMonitor } from '@/composables/useWsMonitor'
import AgentStatesSection from './AgentStatesSection.vue'
import HookTimelineSection from './HookTimelineSection.vue'
import TerminalRegistrySection from './TerminalRegistrySection.vue'
import WsMonitorSection from './WsMonitorSection.vue'
const wsMonitor = useWsMonitor()
onMounted(() => wsMonitor.start(5000))
onUnmounted(() => wsMonitor.stop())
</script>
<template>
<div class="sync-engine-panel">
<div class="se-section">
<h3 class="se-section-title">Agent States</h3>
<AgentStatesSection />
</div>
<div class="se-section">
<h3 class="se-section-title">Hook Timeline</h3>
<HookTimelineSection />
</div>
<div class="se-section">
<h3 class="se-section-title">Terminal Registry</h3>
<TerminalRegistrySection />
</div>
<div class="se-section">
<h3 class="se-section-title">WS Monitor</h3>
<WsMonitorSection :data="wsMonitor.data.value" :error="wsMonitor.error.value" />
</div>
</div>
</template>
<style scoped>
.sync-engine-panel {
position: relative;
z-index: 1;
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 16px;
font-family: 'Courier New', ui-monospace, monospace;
color: #cbd5e1;
}
.se-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.se-section-title {
margin: 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #64748b;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSessionState, type PtySessionState } from '@/stores/session-state'
const sessionState = useSessionState()
const expandedIds = ref<Set<string>>(new Set())
function toggle(id: string) {
const s = new Set(expandedIds.value)
s.has(id) ? s.delete(id) : s.add(id)
expandedIds.value = s
}
function getHooks(ephemeralSessionId: string): PtySessionState | null {
return sessionState.ptySessions[ephemeralSessionId] ?? null
}
const EVENT_COLORS: Record<string, string> = {
SessionStart: '#60a5fa',
UserPromptSubmit: '#a78bfa',
PreToolUse: '#fbbf24',
PermissionRequest: '#fb923c',
PostToolUse: '#4ade80',
PostToolUseFailure: '#f87171',
Notification: '#38bdf8',
SubagentStart: '#c084fc',
SubagentStop: '#a855f7',
Stop: '#22d3ee',
TeammateIdle: '#94a3b8',
TaskCompleted: '#34d399',
ConfigChange: '#e879f9',
PreCompact: '#f59e0b',
SessionEnd: '#6b7280',
}
const STATUS_COLORS: Record<string, string> = {
idle: '#6b7280',
thinking: '#a78bfa',
reading: '#60a5fa',
writing: '#fbbf24',
toolUse: '#fb923c',
permissionRequest: '#f87171',
interrupted: '#ef4444',
error: '#ef4444',
sessionStart: '#4ade80',
sessionEnd: '#6b7280',
}
function elapsed(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
}
function elapsedMs(ts: number): string {
if (!ts) return '-'
const diff = Date.now() - ts
if (diff < 1000) return '<1s'
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
return `${Math.floor(diff / 3600_000)}h`
}
function formatTime(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
const COLS = 8
</script>
<template>
<div class="terminal-registry">
<div v-if="!sessionState.terminalRegistry.length" class="empty">No terminals registered</div>
<table v-else class="reg-table">
<thead>
<tr>
<th></th>
<th>Label</th>
<th>Agent</th>
<th>Session</th>
<th>Command</th>
<th>Buffer</th>
<th>Clients</th>
<th>Age</th>
</tr>
</thead>
<tbody v-for="entry in sessionState.terminalRegistry" :key="entry.ephemeralSessionId">
<!-- Main row -->
<tr
:class="['entry-row', { expanded: expandedIds.has(entry.ephemeralSessionId) }]"
@click="toggle(entry.ephemeralSessionId)"
>
<td><span :class="['dot', entry.alive ? 'alive' : 'dead']"></span></td>
<td class="cell-wrap">{{ entry.label }}</td>
<td class="agent">{{ entry.agent }}</td>
<td class="mono cell-wrap">{{ entry.ephemeralSessionId }}</td>
<td class="mono cell-wrap">{{ entry.command }}</td>
<td class="num">{{ entry.bufferSize.toLocaleString() }}</td>
<td class="num">{{ entry.clients }}</td>
<td class="dim">{{ elapsed(entry.createdAt) }}</td>
</tr>
<!-- Expanded hooks row -->
<tr v-if="expandedIds.has(entry.ephemeralSessionId)" class="hooks-row">
<td :colspan="COLS" class="hooks-cell">
<template v-if="getHooks(entry.ephemeralSessionId)">
<div class="hooks-panel">
<!-- Session state summary -->
<div class="hooks-summary">
<span class="hooks-label">Status</span>
<span class="hooks-status" :style="{ color: STATUS_COLORS[getHooks(entry.ephemeralSessionId)!.status] || '#6b7280' }">
{{ getHooks(entry.ephemeralSessionId)!.status }}
</span>
<template v-if="getHooks(entry.ephemeralSessionId)!.currentTool">
<span class="hooks-label">Tool</span>
<span class="hooks-tool">{{ getHooks(entry.ephemeralSessionId)!.currentTool!.name }}</span>
</template>
<template v-if="getHooks(entry.ephemeralSessionId)!.lastHookEvent">
<span class="hooks-label">Last event</span>
<span class="hooks-event" :style="{ color: EVENT_COLORS[getHooks(entry.ephemeralSessionId)!.lastHookEvent!] || '#94a3b8' }">
{{ getHooks(entry.ephemeralSessionId)!.lastHookEvent }}
</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.lastHookDetail" class="hooks-detail">
{{ getHooks(entry.ephemeralSessionId)!.lastHookDetail }}
</span>
</template>
<span class="hooks-label">Activity</span>
<span class="hooks-dim">{{ elapsedMs(getHooks(entry.ephemeralSessionId)!.lastActivity) }} ago</span>
<!-- Flags -->
<div class="hooks-flags">
<span v-if="getHooks(entry.ephemeralSessionId)!.sessionActive" class="hflag active">session</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.agentResponding" class="hflag responding">responding</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.subagentActive" class="hflag subagent">subagent</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.compacting" class="hflag compacting">compacting</span>
</div>
</div>
<!-- Hook history -->
<div class="hooks-history">
<div class="hooks-history-title">
Hook History ({{ getHooks(entry.ephemeralSessionId)!.hookHistory.length }})
</div>
<div class="hooks-list">
<div
v-for="(h, i) in [...getHooks(entry.ephemeralSessionId)!.hookHistory].reverse().slice(0, 100)"
:key="i"
class="hook-entry"
>
<span class="hook-time">{{ formatTime(h.timestamp) }}</span>
<span class="hook-event" :style="{ color: EVENT_COLORS[h.event] || '#94a3b8' }">{{ h.event }}</span>
<span v-if="h.detail" class="hook-detail">{{ h.detail }}</span>
</div>
<div v-if="getHooks(entry.ephemeralSessionId)!.hookHistory.length > 100" class="hooks-more">
... {{ getHooks(entry.ephemeralSessionId)!.hookHistory.length - 100 }} more
</div>
</div>
</div>
</div>
</template>
<div v-else class="hooks-empty">No hook data for terminal {{ entry.ephemeralSessionId }}</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.terminal-registry {
overflow-x: auto;
}
.empty {
color: #6b7280;
font-size: 11px;
padding: 8px;
}
.reg-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
table-layout: auto;
}
.reg-table th {
text-align: left;
color: #64748b;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
white-space: nowrap;
}
.reg-table td {
padding: 3px 6px;
color: #cbd5e1;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
vertical-align: top;
}
.entry-row {
cursor: pointer;
transition: background 0.1s;
}
.entry-row:hover td {
background: rgba(255, 255, 255, 0.03);
}
.entry-row.expanded td {
background: rgba(14, 165, 233, 0.04);
border-bottom-color: transparent;
}
.cell-wrap {
word-break: break-all;
max-width: 300px;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
}
.dot.alive { background: #22c55e; }
.dot.dead { background: #ef4444; }
.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
}
.agent {
font-weight: 600;
color: #60a5fa;
white-space: nowrap;
}
.num {
text-align: right;
font-family: 'Courier New', monospace;
font-size: 10px;
color: #94a3b8;
white-space: nowrap;
}
.dim {
color: #64748b;
font-size: 10px;
white-space: nowrap;
}
/* ── Hooks expanded row ── */
.hooks-row td {
padding: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.hooks-cell {
background: rgba(0, 0, 0, 0.2);
}
.hooks-panel {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hooks-summary {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 11px;
}
.hooks-label {
color: #475569;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.hooks-status {
font-weight: 700;
}
.hooks-tool {
color: #fbbf24;
font-weight: 600;
}
.hooks-event {
font-weight: 600;
}
.hooks-detail {
color: #64748b;
font-size: 10px;
}
.hooks-dim {
color: #64748b;
font-size: 10px;
}
.hooks-flags {
display: flex;
gap: 4px;
margin-left: 4px;
}
.hflag {
padding: 0 4px;
border-radius: 2px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.hflag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
.hflag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
.hflag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
.hflag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.hooks-empty {
padding: 8px 10px;
color: #475569;
font-size: 10px;
font-style: italic;
}
/* ── Hook history list ── */
.hooks-history {
display: flex;
flex-direction: column;
gap: 3px;
}
.hooks-history-title {
color: #475569;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.hooks-list {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px;
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.hook-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 2px;
font-size: 10px;
}
.hook-time {
color: #475569;
font-family: 'Courier New', monospace;
font-size: 9px;
min-width: 80px;
flex-shrink: 0;
}
.hook-event {
font-weight: 600;
font-size: 10px;
min-width: 110px;
flex-shrink: 0;
}
.hook-detail {
color: #64748b;
font-size: 9px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hooks-more {
color: #475569;
font-size: 9px;
padding: 2px;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import type { WsMonitorData } from '@/composables/useWsMonitor'
const props = defineProps<{
data: WsMonitorData | null
error: string | null
}>()
function elapsed(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
return `${Math.floor(diff / 3600_000)}h`
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString('en-GB', { hour12: false })
}
</script>
<template>
<div class="ws-monitor">
<div v-if="error" class="error-msg">Failed: {{ error }}</div>
<div v-else-if="!data" class="loading">Loading...</div>
<template v-else>
<div class="server-cards">
<!-- Terminal Server -->
<div class="server-card">
<div class="server-header">
<span class="server-name">Terminal Server</span>
<span class="port">:4103</span>
<span :class="['status-dot', data.terminal.status === 'ok' ? 'ok' : 'err']"></span>
</div>
<div class="stat-row">
<span class="stat-label">Broadcast clients</span>
<span class="stat-value">{{ data.terminal.broadcastClients }}</span>
</div>
<div class="stat-row">
<span class="stat-label">PTY sessions</span>
<span class="stat-value">{{ data.terminal.sessions.length }}</span>
</div>
<div v-if="data.terminal.sessions.length" class="pty-list">
<div v-for="s in data.terminal.sessions" :key="s.id" class="pty-entry">
<span class="pty-id">{{ s.id }}</span>
<span class="pty-stat">pid:{{ s.pid }}</span>
<span class="pty-stat">clients:{{ s.clients }}</span>
<span class="pty-stat">buf:{{ s.bufferSize.toLocaleString() }}</span>
<span class="pty-stat dim">{{ elapsed(s.createdAt) }}</span>
</div>
</div>
<div v-if="data.terminal.cwd" class="stat-row">
<span class="stat-label">CWD</span>
<span class="stat-value mono">{{ data.terminal.cwd }}</span>
</div>
</div>
<!-- Sync Server -->
<div class="server-card">
<div class="server-header">
<span class="server-name">Sync Server</span>
<span class="port">:4105</span>
<span :class="['status-dot', data.sync.status === 'ok' ? 'ok' : 'err']"></span>
</div>
<div class="stat-row">
<span class="stat-label">Connected clients</span>
<span class="stat-value">{{ data.sync.clients }}</span>
</div>
<div v-if="data.sync.torch" class="stat-row">
<span class="stat-label">Torch</span>
<span class="stat-value dim">active</span>
</div>
</div>
</div>
<div class="refresh-info">
Last poll: {{ formatTimestamp(data.timestamp) }}
</div>
</template>
</div>
</template>
<style scoped>
.ws-monitor {
display: flex;
flex-direction: column;
gap: 8px;
}
.error-msg {
color: #f87171;
font-size: 11px;
padding: 4px;
}
.loading {
color: #64748b;
font-size: 11px;
padding: 4px;
}
.server-cards {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.server-card {
flex: 1;
min-width: 200px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
padding: 8px 10px;
font-size: 11px;
}
.server-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.server-name {
font-weight: 700;
color: #e2e8f0;
font-size: 12px;
}
.port {
color: #64748b;
font-family: 'Courier New', monospace;
font-size: 10px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: auto;
}
.status-dot.ok { background: #22c55e; }
.status-dot.err { background: #ef4444; }
.stat-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.stat-label {
color: #64748b;
font-size: 10px;
}
.stat-value {
color: #cbd5e1;
font-weight: 600;
}
.stat-value.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
font-weight: 400;
}
.stat-value.dim {
color: #94a3b8;
font-weight: 400;
}
.pty-list {
margin: 4px 0;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.pty-entry {
display: flex;
gap: 8px;
padding: 1px 0;
font-size: 10px;
}
.pty-id {
color: #60a5fa;
font-family: 'Courier New', monospace;
font-weight: 600;
min-width: 60px;
}
.pty-stat {
color: #94a3b8;
font-family: 'Courier New', monospace;
}
.pty-stat.dim {
color: #475569;
}
.refresh-info {
color: #475569;
font-size: 9px;
text-align: right;
}
</style>

View File

@@ -0,0 +1 @@
export { default as SyncEnginePanel } from './SyncEnginePanel.vue'

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
const props = defineProps<{
call: ParsedToolCall
}>()
interface QuestionOption {
label: string
description?: string
}
interface QuestionItem {
question: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
const questions = computed<QuestionItem[]>(() => {
const qs = props.call.input?.questions
if (Array.isArray(qs)) return qs as QuestionItem[]
return []
})
// Parse the answer from the result
const answer = computed<Record<string, string>>(() => {
if (!props.call.result?.content) return {}
const raw = props.call.result.content
// Try to extract "answered: X" pattern from result text
// Format: 'User has answered your questions: "question"="answer"'
const answerMap: Record<string, string> = {}
const regex = /"([^"]+)"="([^"]+)"/g
let match
while ((match = regex.exec(raw)) !== null) {
answerMap[match[1]] = match[2]
}
// Also try JSON format { answers: { ... } }
try {
const parsed = JSON.parse(raw)
if (parsed?.answers) {
for (const [k, v] of Object.entries(parsed.answers)) {
answerMap[k] = String(v)
}
}
} catch { /* not JSON */ }
return answerMap
})
// Check if an option was selected for a given question
function isSelected(questionText: string, optionLabel: string): boolean {
const ans = answer.value[questionText]
if (!ans) return false
return ans.split(', ').includes(optionLabel)
}
// Get user notes from result (text after "user notes:")
const userNotes = computed<string>(() => {
if (!props.call.result?.content) return ''
const match = props.call.result.content.match(/user notes:\s*(.+?)\.?\s*(?:You can|$)/i)
return match ? match[1].trim() : ''
})
const isError = computed(() => props.call.result?.isError ?? false)
</script>
<template>
<div :class="['question-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</span>
<span class="card-label">AskUserQuestion</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<div v-for="(q, qi) in questions" :key="qi" class="question-block">
<div class="question-text">
<span v-if="q.header" class="question-tag">{{ q.header }}</span>
{{ q.question }}
<span v-if="q.multiSelect" class="multi-hint">(multiple)</span>
</div>
<div v-if="q.options?.length" class="options-grid">
<div
v-for="opt in q.options"
:key="opt.label"
:class="['option-item', { selected: isSelected(q.question, opt.label) }]"
>
<span class="option-check">
<svg v-if="isSelected(q.question, opt.label)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
<div class="option-content">
<span class="option-label">{{ opt.label }}</span>
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
</div>
</div>
</div>
<!-- Show answer if it's custom text (not matching any option) -->
<div v-if="answer[q.question] && !q.options?.some(o => isSelected(q.question, o.label))" class="custom-answer">
<span class="answer-label">Answer:</span>
<span class="answer-text">{{ answer[q.question] }}</span>
</div>
</div>
<!-- User notes -->
<div v-if="userNotes" class="user-notes">
<span class="notes-label">Notes:</span>
<span class="notes-text">{{ userNotes }}</span>
</div>
</div>
<!-- Result status -->
<div v-if="call.result" :class="['card-footer', { error: isError }]">
<span class="result-icon">{{ isError ? '✗' : '✓' }}</span>
<span class="result-label">{{ isError ? 'Error' : 'Answered' }}</span>
</div>
</div>
</template>
<style scoped>
.question-card {
border: 1px solid rgba(14, 165, 233, 0.25);
border-left: 3px solid #0ea5e9;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.question-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(14, 165, 233, 0.06);
border-bottom: 1px solid rgba(14, 165, 233, 0.12);
}
.question-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #0ea5e9;
}
.question-card.error .card-icon {
color: #ef4444;
}
.card-label {
font-size: 11px;
font-weight: 600;
color: #0ea5e9;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.question-card.error .card-label {
color: #ef4444;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.question-block {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.question-block + .question-block {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.question-text {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
line-height: 1.4;
}
.question-tag {
display: inline-block;
background: rgba(14, 165, 233, 0.12);
color: #0ea5e9;
font-size: 10px;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
margin-right: 0.3rem;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.multi-hint {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
}
.options-grid {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.option-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.15s;
}
.option-item.selected {
border-color: #0ea5e9;
background: rgba(14, 165, 233, 0.08);
}
.option-check {
width: 16px;
height: 16px;
min-width: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
border: 1.5px solid var(--border-color);
border-radius: 4px;
color: transparent;
}
.option-item.selected .option-check {
background: #0ea5e9;
border-color: #0ea5e9;
color: white;
}
.option-content {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.option-label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.option-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
.custom-answer {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background: rgba(14, 165, 233, 0.06);
border: 1px solid rgba(14, 165, 233, 0.15);
border-radius: 6px;
}
.answer-label {
font-size: 11px;
font-weight: 600;
color: #0ea5e9;
white-space: nowrap;
}
.answer-text {
font-size: 12px;
color: var(--text-primary);
font-style: italic;
}
.user-notes {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.6rem;
background: rgba(251, 191, 36, 0.06);
border: 1px solid rgba(251, 191, 36, 0.15);
border-radius: 6px;
}
.notes-label {
font-size: 11px;
font-weight: 600;
color: #fbbf24;
white-space: nowrap;
}
.notes-text {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-top: 1px solid rgba(14, 165, 233, 0.12);
background: rgba(34, 197, 94, 0.04);
font-size: 11px;
}
.card-footer.error {
background: rgba(239, 68, 68, 0.04);
border-top-color: rgba(239, 68, 68, 0.12);
}
.result-icon {
font-weight: 600;
color: #22c55e;
}
.card-footer.error .result-icon {
color: #ef4444;
}
.result-label {
font-weight: 500;
color: var(--text-muted);
}
</style>

Some files were not shown because too many files have changed in this diff Show More