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:
@@ -32,3 +32,8 @@ build/
|
|||||||
/*.png
|
/*.png
|
||||||
/*.jpg
|
/*.jpg
|
||||||
/*.jpeg
|
/*.jpeg
|
||||||
|
|
||||||
|
# Desktop pet (Electron)
|
||||||
|
desktop/node_modules/
|
||||||
|
desktop/dist/
|
||||||
|
desktop/dist-electron/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
**快速安装:**
|
**快速安装:**
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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) 并登录
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+5354
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
Vendored
+22
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user