feat(desktop): add Electron desktop pet for Claude CLI

- Electron + React + TypeScript 桌面宠物外壳
- xterm.js + node-pty 直接对接 Claude CLI 进程
- 宠物 orb(80x80)可拖拽,点击展开终端面板
- CRT 复古终端主题(扫描线、文字发光)
- 全局快捷键 Cmd+Shift+C 显隐
- 窗口位置自动保存
- 中华田园犬吉祥物 icon(AI 生成)
- Makefile 新增 `make app` 打包命令
This commit is contained in:
Zhengshou Lai
2026-05-27 11:23:14 +08:00
parent 75eaabbfb5
commit 0c227cae19
26 changed files with 6627 additions and 3 deletions
+5
View File
@@ -32,3 +32,8 @@ build/
/*.png /*.png
/*.jpg /*.jpg
/*.jpeg /*.jpeg
# Desktop pet (Electron)
desktop/node_modules/
desktop/dist/
desktop/dist-electron/
+18 -1
View File
@@ -3,13 +3,14 @@ VENV_MYCLAUDE := $(ROOT_DIR)/.venv/bin/myclaude
USER_LOCAL_MYCLAUDE := $(HOME)/.local/bin/myclaude USER_LOCAL_MYCLAUDE := $(HOME)/.local/bin/myclaude
COMP_DIR := $(HOME)/.local/bin/completions COMP_DIR := $(HOME)/.local/bin/completions
.PHONY: help install uninstall _symlink-myclaude _install-completions _uninstall-completions .PHONY: help install uninstall app _symlink-myclaude _install-completions _uninstall-completions
help: help:
@echo "Usage: make [target]" @echo "Usage: make [target]"
@echo "" @echo ""
@echo " install Sync venv, symlink bin, install completions" @echo " install Sync venv, symlink bin, install completions"
@echo " uninstall Remove bin and completions" @echo " uninstall Remove bin and completions"
@echo " app Build desktop pet as macOS .app"
install: install:
@cd "$(ROOT_DIR)" && \ @cd "$(ROOT_DIR)" && \
@@ -37,3 +38,19 @@ uninstall: _uninstall-completions
_uninstall-completions: _uninstall-completions:
@rm -f "$(COMP_DIR)/_myclaude" "$(COMP_DIR)/myclaude.bash" @rm -f "$(COMP_DIR)/_myclaude" "$(COMP_DIR)/myclaude.bash"
@echo "Removed completions" @echo "Removed completions"
app:
@cd "$(ROOT_DIR)/desktop" && \
if [ ! -d "node_modules" ]; then \
echo "Installing desktop dependencies..."; \
npm install; \
fi && \
echo "Rebuilding native modules for Electron..." && \
npx electron-rebuild && \
echo "Building and packaging..." && \
npm run pack && \
echo "" && \
echo "Done. .app bundle:" && \
ls -d "$(ROOT_DIR)/desktop/release"/*/*.app 2>/dev/null || \
ls -d "$(ROOT_DIR)/desktop/release"/*.app 2>/dev/null || \
echo "$(ROOT_DIR)/desktop/release/mac*/MyClaude.app"
+36 -1
View File
@@ -20,6 +20,10 @@ myclaude/
├── .claude/ # Claude Code 配置 ├── .claude/ # Claude Code 配置
│ └── skills/ # 自定义技能 │ └── skills/ # 自定义技能
├── .venv/ # Python 虚拟环境 ├── .venv/ # Python 虚拟环境
├── desktop/ # 桌面宠物(Electron + React
│ ├── electron/ # 主进程
│ ├── src/ # React 前端
│ └── package.json
├── templates/ # 模板目录 ├── templates/ # 模板目录
│ └── workspace/ # 工作区模板(CLAUDE.md, .gitignore │ └── workspace/ # 工作区模板(CLAUDE.md, .gitignore
├── bin/ # Python 源码 ├── bin/ # Python 源码
@@ -175,7 +179,38 @@ fi
Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 该配置文件。 Bash 将 `zsh_source` 换成 `bash_source`。保存后 `source` 该配置文件。
### 5. 安装 Metabot ### 5. 安装桌面宠物(可选)
基于 Electron + React 的桌面宠物,常驻屏幕角落,点击展开终端聊天框。
**前置依赖:**
| 工具 | 版本要求 | 说明 |
|------|---------|------|
| Node.js | 20+ | JavaScript 运行时 |
| Claude CLI | - | 已安装并登录 |
**安装运行:**
```bash
cd ~/Documents/myBin/myclaude/desktop
npm install
npm run rebuild # 编译原生模块(node-pty
npm run dev # 开发模式
```
**快捷键:**
- `Cmd+Shift+C`macOS/ `Ctrl+Shift+C`Win/Linux):显示/隐藏宠物
**特性:**
- 会话保持 — Claude CLI 进程常驻,收起/展开不影响对话
- 宠物外壳 — 80x80 悬浮 orb,可拖拽,呼吸动画
- CRT 终端 — 复古终端风格,扫描线 + 文字发光效果
- 窗口位置记忆 — 自动保存位置
### 6. 安装 Metabot
**快速安装:** **快速安装:**
+184
View File
@@ -0,0 +1,184 @@
"""Sync workspace symlinks from zshrc environment variables."""
import os
import subprocess
from pathlib import Path
from rich.console import Console
from bin.project_root import get_myclaude_project_root, get_workspace_root
console = Console()
stderr_console = Console(stderr=True)
# Link name -> list of environment variable names to try, in order.
_LINK_MAP: dict[str, list[str]] = {
"myacademia": ["path_myacademia"],
"myslides": ["path_myslides", "MYSLIDES_ROOT"],
"metabot": ["METABOT_HOME"],
"mytoolkit": ["path_mytoolkit", "MYTOOLKIT_ROOT"],
"mywebpage": ["path_mywebpage", "MYWEBPAGE_ROOT"],
}
def _read_zsh_env(var: str, zshrc: Path) -> str | None:
"""Source zshrc and print the value of a variable."""
cmd = [
"zsh",
"-c",
f"source '{zshrc}' >/dev/null 2>&1; echo -n ${var}",
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
return None
value = result.stdout.strip()
return value if value else None
def _resolve_target(
env_vars: list[str],
zshrc: Path,
) -> Path | None:
"""Find the first valid target path from environment variables."""
for var in env_vars:
raw = _read_zsh_env(var, zshrc)
if raw is None:
continue
expanded = os.path.expandvars(os.path.expanduser(raw))
path = Path(expanded)
if path.exists():
return path.resolve()
return None
def _current_link_target(workspace: Path, name: str) -> Path | None:
"""Return the target of an existing symlink, or None."""
link = workspace / name
if link.is_symlink():
try:
return Path(os.readlink(link))
except OSError:
return None
return None
def _sync_link(
workspace: Path,
name: str,
target: Path,
dry_run: bool,
force: bool,
) -> bool:
"""Create or update a symlink. Returns True if changed."""
link = workspace / name
current = _current_link_target(workspace, name)
if current is not None and current.resolve() == target.resolve():
return False
if link.exists() and not link.is_symlink():
if not force:
stderr_console.print(
f"[yellow]{name} exists but is not a symlink. "
"Use --force to replace.[/yellow]"
)
return False
if dry_run:
console.print(
f"[dry-run] Would remove {name} and symlink -> {target}"
)
return True
if link.is_dir():
link.rmdir()
else:
link.unlink()
elif current is not None:
if dry_run:
console.print(
f"[dry-run] Would update {name}: {current} -> {target}"
)
return True
link.unlink()
else:
if dry_run:
console.print(f"[dry-run] Would create {name} -> {target}")
return True
link.symlink_to(target, target_is_directory=target.is_dir())
return True
def sync_workspace_links(
zshrc: Path | None = None,
dry_run: bool = False,
force: bool = False,
verbose: bool = True,
) -> bool:
"""Sync all workspace symlinks. Returns True on success."""
workspace = get_workspace_root()
if zshrc is None:
zshrc = Path.home() / ".zshrc"
if verbose:
console.print(f"[bold]Workspace:[/bold] {workspace}")
console.print(f"[bold]Zshrc:[/bold] {zshrc}")
console.print()
changed = 0
skipped = 0
broken = 0
# Handle myclaude separately: use project root detection, not zshrc.
myclaude_target = get_myclaude_project_root()
if _sync_link(workspace, "myclaude", myclaude_target, dry_run, force):
action = "would update" if dry_run else "updated"
if verbose:
console.print(
f"[green]myclaude: {action} -> {myclaude_target}[/green]"
)
changed += 1
else:
if verbose:
console.print("[dim]myclaude: already correct[/dim]")
for name, env_vars in _LINK_MAP.items():
target = _resolve_target(env_vars, zshrc)
if target is None:
link = workspace / name
if link.is_symlink() and not link.exists():
if verbose:
stderr_console.print(
f"[red]{name}: broken symlink (no env var found)[/red]"
)
broken += 1
else:
if verbose:
console.print(f"[dim]{name}: skipped (no env var)[/dim]")
skipped += 1
continue
if _sync_link(workspace, name, target, dry_run, force):
action = "would update" if dry_run else "updated"
if verbose:
console.print(f"[green]{name}: {action} -> {target}[/green]")
changed += 1
else:
if verbose:
console.print(f"[dim]{name}: already correct[/dim]")
if verbose:
console.print()
label = "Preview" if dry_run else "Done"
console.print(
f"[bold]{label}:[/bold] {changed} changed, "
f"{skipped} skipped, {broken} broken."
)
return broken == 0
+6 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
import click import click
from rich.console import Console from rich.console import Console
from bin.commands.init import sync_workspace_links
from bin.project_root import get_myclaude_project_root from bin.project_root import get_myclaude_project_root
console = Console() console = Console()
@@ -28,7 +29,7 @@ def _run_pip_editable(root: Path) -> int:
@click.command("update") @click.command("update")
def update_cmd() -> None: def update_cmd() -> None:
"""Reinstall myclaude from this repository (make install, or pip install -e).""" """Reinstall myclaude and sync workspace symlinks."""
root = get_myclaude_project_root() root = get_myclaude_project_root()
fallback = Path.home() / ".myclaude" fallback = Path.home() / ".myclaude"
if root == fallback: if root == fallback:
@@ -54,3 +55,7 @@ def update_cmd() -> None:
stderr_console.print("[red]Update failed.[/red]") stderr_console.print("[red]Update failed.[/red]")
raise SystemExit(rc) raise SystemExit(rc)
console.print("[green]myclaude updated.[/green]") console.print("[green]myclaude updated.[/green]")
console.print()
console.print("[bold cyan]Syncing workspace links…[/bold cyan]")
sync_workspace_links()
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
dist-electron/
release/
*.log
.DS_Store
+33
View File
@@ -0,0 +1,33 @@
# MyClaude Pet
桌面宠物外壳 for Claude CLI。常驻屏幕角落,点击展开终端聊天框。
## 特性
- **会话保持** — Claude CLI 进程常驻后台,收起/展开不影响对话
- **宠物外壳** — 80x80 悬浮 orb,可拖拽,呼吸动画
- **CRT 终端** — 复古终端风格,扫描线 + 文字发光效果
- **全局快捷键** — `Cmd+Shift+C`macOS/ `Ctrl+Shift+C`Win/Linux)快速显示/隐藏
- **位置记忆** — 窗口位置自动保存
## 开发
```bash
npm install
npm run rebuild # 编译原生模块(node-pty
npm run dev # 启动开发环境
```
## 构建
```bash
npm run build
```
前端输出到 `dist/`Electron 输出到 `dist-electron/`
## 要求
- macOS / Windows / Linux
- Node.js 20+
- 已安装 [Claude CLI](https://claude.ai/download) 并登录
+40
View File
@@ -0,0 +1,40 @@
import * as fs from 'fs'
import * as path from 'path'
import { app } from 'electron'
interface AppConfig {
windowPosition?: { x: number; y: number }
autoStart?: boolean
collapsed?: boolean
}
const DEFAULT_CONFIG: AppConfig = {
autoStart: false,
collapsed: true,
}
function getConfigPath(): string {
return path.join(app.getPath('userData'), 'config.json')
}
export function loadConfig(): AppConfig {
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, 'utf8')
return { ...DEFAULT_CONFIG, ...JSON.parse(data) }
}
} catch (err) {
console.error('Failed to load config:', err)
}
return { ...DEFAULT_CONFIG }
}
export function saveConfig(config: AppConfig): void {
try {
const configPath = getConfigPath()
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
} catch (err) {
console.error('Failed to save config:', err)
}
}
+211
View File
@@ -0,0 +1,211 @@
import { app, BrowserWindow, ipcMain, screen, globalShortcut } from 'electron'
import * as path from 'path'
import { PTYManager } from './pty'
import { loadConfig, saveConfig } from './config'
const ptyManager = new PTYManager()
let mainWindow: BrowserWindow | null = null
const COLLAPSED_SIZE = { width: 80, height: 80 }
const EXPANDED_SIZE = { width: 900, height: 600 }
function createWindow() {
const config = loadConfig()
const display = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = display.workAreaSize
// Use saved position or default to bottom-right
let x = config.windowPosition?.x ?? screenWidth - COLLAPSED_SIZE.width - 20
let y = config.windowPosition?.y ?? screenHeight - COLLAPSED_SIZE.height - 20
mainWindow = new BrowserWindow({
width: COLLAPSED_SIZE.width,
height: COLLAPSED_SIZE.height,
x,
y,
frame: false,
transparent: true,
backgroundColor: '#00000000',
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
resizable: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
// Dock hide on macOS
if (app.dock) {
app.dock.hide()
}
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
// Save position when moved
mainWindow.on('moved', () => {
if (!mainWindow) return
const [x, y] = mainWindow.getPosition()
const config = loadConfig()
saveConfig({ ...config, windowPosition: { x, y } })
})
}
function expandWindow() {
if (!mainWindow) return
const display = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = display.workAreaSize
const currentPos = mainWindow.getPosition()
let x = currentPos[0]
let y = currentPos[1]
// Ensure window stays on screen
if (x + EXPANDED_SIZE.width > screenWidth) {
x = screenWidth - EXPANDED_SIZE.width - 20
}
if (y + EXPANDED_SIZE.height > screenHeight) {
y = screenHeight - EXPANDED_SIZE.height - 20
}
mainWindow.setResizable(true)
mainWindow.setMinimumSize(EXPANDED_SIZE.width, EXPANDED_SIZE.height)
mainWindow.setSize(EXPANDED_SIZE.width, EXPANDED_SIZE.height)
mainWindow.setPosition(x, y)
mainWindow.setAlwaysOnTop(true)
mainWindow.setIgnoreMouseEvents(false)
mainWindow.setHasShadow(true)
const config = loadConfig()
saveConfig({ ...config, collapsed: false })
}
function collapseWindow() {
if (!mainWindow) return
const currentPos = mainWindow.getPosition()
mainWindow.setResizable(false)
mainWindow.setSize(COLLAPSED_SIZE.width, COLLAPSED_SIZE.height)
mainWindow.setPosition(currentPos[0], currentPos[1])
mainWindow.setAlwaysOnTop(true)
mainWindow.setIgnoreMouseEvents(true, { forward: true })
mainWindow.setHasShadow(false)
const config = loadConfig()
saveConfig({ ...config, collapsed: true, windowPosition: { x: currentPos[0], y: currentPos[1] } })
}
function toggleWindow() {
if (!mainWindow) {
createWindow()
return
}
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
}
}
app.whenReady().then(() => {
createWindow()
ptyManager.start('claude')
// Collapse after load to ensure pet mode
mainWindow?.webContents.on('did-finish-load', () => {
collapseWindow()
})
// Register global shortcut
const ret = globalShortcut.register('CommandOrControl+Shift+C', () => {
if (!mainWindow) {
createWindow()
return
}
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
})
if (!ret) {
console.log('Global shortcut registration failed')
}
// IPC handlers
ipcMain.on('pty:write', (_event, data: string) => {
ptyManager.write(data)
})
ipcMain.on('pty:resize', (_event, cols: number, rows: number) => {
ptyManager.resize(cols, rows)
})
ipcMain.on('window:expand', () => {
expandWindow()
})
ipcMain.on('window:collapse', () => {
collapseWindow()
})
ipcMain.on('window:set-position', (_event, x: number, y: number) => {
mainWindow?.setPosition(x, y)
})
ipcMain.on('window:set-ignore-mouse', (_event, ignore: boolean) => {
mainWindow?.setIgnoreMouseEvents(ignore, { forward: true })
})
ipcMain.on('window:close', () => {
mainWindow?.close()
})
ipcMain.on('window:minimize', () => {
mainWindow?.minimize()
})
ptyManager.on('data', (data: string) => {
mainWindow?.webContents.send('pty:data', data)
})
ptyManager.on('exit', (code: number) => {
mainWindow?.webContents.send('pty:exit', code)
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
ptyManager.kill()
globalShortcut.unregisterAll()
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
ptyManager.kill()
globalShortcut.unregisterAll()
})
app.on('will-quit', () => {
globalShortcut.unregisterAll()
})
+24
View File
@@ -0,0 +1,24 @@
const { contextBridge, ipcRenderer } = require('electron')
const api = {
pty: {
write: (data) => ipcRenderer.send('pty:write', data),
resize: (cols, rows) => ipcRenderer.send('pty:resize', cols, rows),
onData: (callback) => {
ipcRenderer.on('pty:data', (_event, data) => callback(data))
},
onExit: (callback) => {
ipcRenderer.on('pty:exit', (_event, code) => callback(code))
},
},
window: {
expand: () => ipcRenderer.send('window:expand'),
collapse: () => ipcRenderer.send('window:collapse'),
setPosition: (x, y) => ipcRenderer.send('window:set-position', x, y),
setIgnoreMouseEvents: (ignore) => ipcRenderer.send('window:set-ignore-mouse', ignore),
close: () => ipcRenderer.send('window:close'),
minimize: () => ipcRenderer.send('window:minimize'),
},
}
contextBridge.exposeInMainWorld('petAPI', api)
+65
View File
@@ -0,0 +1,65 @@
import * as pty from 'node-pty'
import { execSync } from 'child_process'
import { EventEmitter } from 'events'
export class PTYManager extends EventEmitter {
private ptyProcess: pty.IPty | null = null
start(shell: string = 'claude', args: string[] = []) {
const resolvedShell = this.resolveShell(shell)
const resolvedArgs = shell === 'claude' ? args : []
this.ptyProcess = pty.spawn(resolvedShell, resolvedArgs, {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: process.env.HOME,
env: process.env as { [key: string]: string },
})
this.ptyProcess.onData((data: string) => {
this.emit('data', data)
})
this.ptyProcess.onExit(({ exitCode }) => {
this.emit('exit', exitCode)
})
return this.ptyProcess
}
write(data: string) {
if (this.ptyProcess) {
this.ptyProcess.write(data)
}
}
resize(cols: number, rows: number) {
if (this.ptyProcess) {
this.ptyProcess.resize(cols, rows)
}
}
kill() {
if (this.ptyProcess) {
this.ptyProcess.kill()
this.ptyProcess = null
}
}
private resolveShell(shell: string): string {
if (shell === 'claude') {
try {
const path = execSync('which claude', { encoding: 'utf8' }).trim()
return path
} catch {
try {
return execSync('which zsh', { encoding: 'utf8' }).trim()
} catch {
return '/bin/zsh'
}
}
}
return shell
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyClaude Pet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5354
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
{
"name": "myclaude-pet",
"version": "1.0.0",
"description": "Desktop pet wrapper for Claude CLI",
"main": "dist-electron/main.js",
"scripts": {
"dev": "npx tsc -p tsconfig.electron.json && concurrently \"vite\" \"tsc -p tsconfig.electron.json --watch --preserveWatchOutput\" \"wait-on http://localhost:5173 && VITE_DEV_SERVER_URL=http://localhost:5173 npx electron dist-electron/main.js\"",
"build": "vite build && tsc -p tsconfig.electron.json && cp electron/preload.js dist-electron/",
"start": "npx electron dist-electron/main.js",
"rebuild": "electron-rebuild",
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder"
},
"build": {
"appId": "com.apaam.myclaude-pet",
"productName": "MyClaude",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"build/**/*"
],
"mac": {
"target": [
{
"target": "dir",
"arch": ["arm64", "x64"]
},
{
"target": "dmg",
"arch": ["arm64", "x64"]
}
],
"category": "public.app-category.productivity",
"hardenedRuntime": true,
"gatekeeperAssess": false
}
},
"keywords": [
"claude",
"desktop",
"pet"
],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"node-pty": "^1.1.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@electron/rebuild": "^4.0.4",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"electron": "^42.0.0",
"electron-builder": "^26.8.1",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"wait-on": "^9.0.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+27
View File
@@ -0,0 +1,27 @@
import React, { useState, useCallback } from 'react'
import TerminalPanel from './components/TerminalPanel'
import PetOrb from './components/PetOrb'
type ViewMode = 'orb' | 'terminal'
const App: React.FC = () => {
const [mode, setMode] = useState<ViewMode>('orb')
const handleExpand = useCallback(() => {
setMode('terminal')
window.petAPI?.window.expand()
}, [])
const handleCollapse = useCallback(() => {
setMode('orb')
window.petAPI?.window.collapse()
}, [])
return mode === 'terminal' ? (
<TerminalPanel onCollapse={handleCollapse} />
) : (
<PetOrb onExpand={handleExpand} />
)
}
export default App
+109
View File
@@ -0,0 +1,109 @@
import React, { useCallback, useRef, useState } from 'react'
interface PetOrbProps {
onExpand: () => void
}
type OrbState = 'idle' | 'thinking' | 'typing'
const PetOrb: React.FC<PetOrbProps> = ({ onExpand }) => {
const [isDragging, setIsDragging] = useState(false)
const [orbState] = useState<OrbState>('idle')
const dragOffset = useRef({ x: 0, y: 0 })
const hasDragged = useRef(false)
const getAnimation = () => {
switch (orbState) {
case 'thinking':
return 'orbThink 0.8s ease-in-out infinite'
case 'typing':
return 'orbType 0.3s ease-in-out infinite'
default:
return 'orbBreathe 3s ease-in-out infinite'
}
}
const handleMouseDown = useCallback((e: React.MouseEvent) => {
hasDragged.current = false
setIsDragging(true)
dragOffset.current = {
x: e.clientX,
y: e.clientY,
}
}, [])
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return
const dx = Math.abs(e.clientX - dragOffset.current.x)
const dy = Math.abs(e.clientY - dragOffset.current.y)
if (dx > 3 || dy > 3) {
hasDragged.current = true
}
const screenX = e.screenX - dragOffset.current.x
const screenY = e.screenY - dragOffset.current.y
window.petAPI?.window.setPosition(screenX, screenY)
},
[isDragging]
)
const handleMouseUp = useCallback(() => {
setIsDragging(false)
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
if (!hasDragged.current) {
onExpand()
}
}, [handleMouseMove, onExpand])
const handleMouseDownWrapper = useCallback(
(e: React.MouseEvent) => {
handleMouseDown(e)
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
},
[handleMouseDown, handleMouseMove, handleMouseUp]
)
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
}}
onMouseDown={handleMouseDownWrapper}
>
<div
style={{
width: '56px',
height: '56px',
borderRadius: '50%',
overflow: 'hidden',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.3), inset 0 2px 4px rgba(255,255,255,0.2)',
animation: getAnimation(),
position: 'relative',
}}
>
<img
src="./icon.png"
alt="MyClaude"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
</div>
</div>
)
}
export default PetOrb
+110
View File
@@ -0,0 +1,110 @@
import React, { useEffect, useRef } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
const Terminal: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<XTerm | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
useEffect(() => {
if (!containerRef.current) return
const term = new XTerm({
fontSize: 14,
fontFamily: '"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
theme: {
background: '#0c0c0c',
foreground: '#4ade80',
cursor: '#4ade80',
selectionBackground: '#14532d',
black: '#0c0c0c',
red: '#f87171',
green: '#4ade80',
yellow: '#facc15',
blue: '#60a5fa',
magenta: '#c084fc',
cyan: '#22d3ee',
white: '#e5e5e5',
brightBlack: '#525252',
brightRed: '#f87171',
brightGreen: '#86efac',
brightYellow: '#fde047',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#ffffff',
},
cursorBlink: true,
scrollback: 10000,
allowProposedApi: true,
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerRef.current)
fitAddon.fit()
xtermRef.current = term
fitAddonRef.current = fitAddon
const api = window.petAPI
if (api) {
api.pty.onData((data: string) => {
term.write(data)
})
api.pty.onExit((code: number) => {
term.writeln(`\r\n\x1b[31mProcess exited with code ${code}\x1b[0m`)
})
term.onData((data: string) => {
api.pty.write(data)
})
const dims = fitAddon.proposeDimensions()
if (dims) {
api.pty.resize(dims.cols, dims.rows)
}
}
const handleResize = () => {
fitAddon.fit()
const dims = fitAddon.proposeDimensions()
if (dims && api) {
api.pty.resize(dims.cols, dims.rows)
}
}
window.addEventListener('resize', handleResize)
const resizeObserver = new ResizeObserver(() => {
handleResize()
})
resizeObserver.observe(containerRef.current)
return () => {
window.removeEventListener('resize', handleResize)
resizeObserver.disconnect()
term.dispose()
}
}, [])
return (
<div className="crt-container crt-flicker" style={{ width: '100%', height: '100%', position: 'relative' }}>
<div className="crt-scanline" />
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
padding: '4px',
}}
/>
</div>
)
}
export default Terminal
+96
View File
@@ -0,0 +1,96 @@
import React from 'react'
import Terminal from './Terminal'
interface TerminalPanelProps {
onCollapse: () => void
}
const TerminalPanel: React.FC<TerminalPanelProps> = ({ onCollapse }) => {
const handleMinimize = () => {
window.petAPI?.window.minimize()
}
const handleClose = () => {
onCollapse()
}
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: '12px',
overflow: 'hidden',
background: '#0c0c0c',
border: '1px solid #333',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
}}
>
<TitleBar onMinimize={handleMinimize} onClose={handleClose} />
<div style={{ flex: 1, overflow: 'hidden', padding: '4px' }}>
<Terminal />
</div>
</div>
)
}
interface TitleBarProps {
onMinimize: () => void
onClose: () => void
}
const TitleBar: React.FC<TitleBarProps> = ({ onMinimize, onClose }) => {
return (
<div
style={{
height: '32px',
background: '#1a1a1a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px',
WebkitAppRegion: 'drag',
userSelect: 'none',
borderBottom: '1px solid #333',
}}
>
<span
style={{
color: '#888',
fontSize: '13px',
fontWeight: 500,
}}
>
myclaude
</span>
<div style={{ display: 'flex', gap: '8px', WebkitAppRegion: 'no-drag' }}>
<button
onClick={onMinimize}
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
border: 'none',
background: '#f5bd4f',
cursor: 'pointer',
}}
/>
<button
onClick={onClose}
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
border: 'none',
background: '#ec6b5e',
cursor: 'pointer',
}}
/>
</div>
</div>
)
}
export default TerminalPanel
+127
View File
@@ -0,0 +1,127 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
#root {
width: 100vw;
height: 100vh;
}
/* CRT Terminal Effects */
.crt-container {
position: relative;
}
.crt-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.08),
rgba(0, 0, 0, 0.08) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 10;
}
.crt-container::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
ellipse at center,
transparent 50%,
rgba(0, 0, 0, 0.3) 100%
);
pointer-events: none;
z-index: 11;
}
/* Terminal text glow */
.xterm-viewport {
background-color: transparent !important;
}
.xterm-screen {
text-shadow: 0 0 2px rgba(74, 222, 128, 0.3), 0 0 4px rgba(74, 222, 128, 0.1) !important;
}
/* Flicker animation */
@keyframes flicker {
0% { opacity: 0.98; }
5% { opacity: 0.95; }
10% { opacity: 0.98; }
15% { opacity: 0.96; }
20% { opacity: 0.99; }
50% { opacity: 0.98; }
52% { opacity: 0.93; }
54% { opacity: 0.98; }
100% { opacity: 0.98; }
}
.crt-flicker {
animation: flicker 4s infinite;
}
/* Scanline sweep */
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
.crt-scanline {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
to bottom,
transparent,
rgba(74, 222, 128, 0.08),
transparent
);
animation: scanline 8s linear infinite;
pointer-events: none;
z-index: 12;
}
/* Pet Orb Animations */
@keyframes orbBreathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
@keyframes orbThink {
0%, 100% { transform: scale(1) rotate(0deg); }
25% { transform: scale(1.1) rotate(-5deg); }
75% { transform: scale(1.1) rotate(5deg); }
}
@keyframes orbType {
0%, 100% { transform: translateY(0); }
25% { transform: translateY(-2px); }
75% { transform: translateY(1px); }
}
@keyframes orbGlow {
0%, 100% { box-shadow: 0 4px 16px rgba(22, 163, 74, 0.4), inset 0 2px 4px rgba(255,255,255,0.2); }
50% { box-shadow: 0 4px 24px rgba(74, 222, 128, 0.6), inset 0 2px 4px rgba(255,255,255,0.3); }
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+22
View File
@@ -0,0 +1,22 @@
export {}
declare global {
interface Window {
petAPI: {
pty: {
write: (data: string) => void
resize: (cols: number, rows: number) => void
onData: (callback: (data: string) => void) => void
onExit: (callback: (code: number) => void) => void
}
window: {
expand: () => void
collapse: () => void
setPosition: (x: number, y: number) => void
setIgnoreMouseEvents: (ignore: boolean) => void
close: () => void
minimize: () => void
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"lib": ["ES2020"],
"outDir": "dist-electron",
"rootDir": "electron",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["electron/**/*"],
"exclude": ["node_modules"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
},
})