* fix(desktop,web): rebuild Electron dev sync + bitmap dragging fix on v0.7.1 (#99)

Re-applies b046a0d from the closed PR #97 now that the base is v0.7.1.
Original conflict against v0.7.0 came from the release branch churn —
the cherry-pick onto v0.7.1 applies cleanly.

Keeps dev-startup, sync-noise, and bitmap-dragging fixes; drops the
loopback proxy helper scripts upstream rejected in PR #92. Readiness
probe now does direct socket checks inside the existing dev entrypoint;
sync hardening stays focused on request diagnostics, backpressure, and
drag-time clip-rect correctness.

Original commit: b046a0d
Supersedes: #97 (closed, head branch deleted)

Co-authored-by: Rais <vdcoolzi@gmail.com>

* fix(canvas): use ImageFill.url in skia-interaction test

The image-backed rectangle fixture in the skia-interaction test used
`{ type: 'image', src: '…' }`, but `ImageFill` in pen-types declares
the field as `url`. `npx tsc --noEmit` flagged it as TS2352 on the
`as PenNode` cast. One-word rename.

The squash-merge of #99 captured an earlier snapshot that did not
include this fix, so re-apply directly on v0.7.1.

* feat(cli): add `op install` / `op uninstall` for openpencil-skill

Bundle skill files at build time (scripts/bundle-skill.ts → skill-bundle.json)
so users without GitHub access can install directly. Falls back to git clone
when the bundle is empty.

Supports auto-detection of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode.
CI workflows updated to checkout openpencil-skill before cli:compile.

* fix(panels): allow reparenting nodes into rectangle in layer panel

CONTAINER_TYPES was missing 'rectangle', preventing drag-drop into
rectangles even though the data model (ContainerProps) and store
(moveNode) both support it.

* fix(agent,ai): tool_exec reset + insert_node with after + move_node + CRUD tools

- fix(agent): reset StreamingToolExecutor between turns — prevents stale
  tool_use IDs that caused 400 errors on multi-turn tool calls (MiniMax etc.)
- feat(ai): add insert_node "after" parameter — auto-resolves sibling's
  parent and position for intuitive node insertion
- feat(ai): add move_node and insert_node to CRUD tool set
- feat(ai): add Chinese keywords (增加/添加/插入) to design intent detection
- fix(ai): insert_node uses addNode directly for existing parents instead
  of streaming pipeline, fixing parent resolution

* feat(ai): route CRUD intents to lightweight prompt and tool set

CRUD operations (read/update/delete) now get a focused system prompt
without design generation instructions, and use getCrudToolDefs()
(which includes insert_node and move_node) instead of the full design set.

* fix(agent,mcp): submodule update + MCP tool improvements

- Update agent-native submodule (tool_exec reset, HTTP error diagnostics)
- Improve MCP tool descriptions and parameter schemas
- Enhance agent.ts error handling

* feat(ai): add PenNode examples to CRUD prompt for complete node generation

The CRUD system prompt now includes button and text node examples
showing the full structure (fills, children, icons, layout) so models
generate complete nodes instead of empty frames.

* Update agent-native submodule to commit f9633a8, ensuring compatibility with recent changes and improvements in the agent's functionality.

* fix(ci,agent): generate skill-bundle before type check + fix moveNode arg

- Add `bun run cli:bundle-skill` step in CI before `tsc --noEmit` so
  skill-bundle.json exists when type-checking the CLI
- Fix moveNode index parameter: default to -1 when undefined

* fix(ci): add pen-engine and pen-react to npm publish workflow

Insert pen-engine and pen-react in topological order between
pen-renderer and pen-mcp so they are published before pen-sdk.

* docs: add MIT LICENSE and README to all packages

- Add MIT LICENSE to pen-ai-skills, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk, pen-types
- Add README.md to pen-engine, pen-react, pen-mcp, pen-ai-skills

* docs: comprehensive package metadata, README, CLAUDE.md, and LICENSE

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add CLAUDE.md to pen-types, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk
- Add MIT LICENSE to all packages
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg → ZSeven-W

* docs: package metadata, README, LICENSE, and CLAUDE.md for all packages

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add MIT LICENSE to all packages missing it
- Add CLAUDE.md to pen-types
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg to ZSeven-W

* docs: rewrite README for pen-core, pen-figma, pen-renderer, pen-sdk

Comprehensive READMEs with full API reference, usage examples,
feature tables, and architecture overview for each package.

* feat(acp): acpAgents store — persist, hydrate, CRUD actions

* feat(acp): pen-acp package + agent settings types + store

- pen-acp/types.ts: AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState
- pen-acp/client.ts: connectAcpAgent (local stdio + remote WebSocket), disconnectAcpAgent
- pen-acp/event-adapter.ts: acpUpdateToSSE (ACP session/update → SSE events)
- agent-settings.ts: AcpAgentConfig type, widen ModelGroup/GroupedModel.provider
- agent-settings-store.ts: acpAgents persist/hydrate/CRUD + acpConnectionStatus

* fix(acp): remove unused type imports in client.ts

* feat(acp): ACP agent settings UI — form, cards, connect/disconnect

* feat(acp): add AcpAgentSection to Agents settings tab

* refactor(agent): AgentSession as discriminated union (native | acp)

* feat(acp): connection manager — connect, disconnect, cleanup

* feat(acp): connect/disconnect API route

* feat(acp): ACP branch in agent result handler

* fix(agent): use NativeAgentSession type for runDelegateMember

* feat(acp): ACP prompt SSE stream in agent endpoint

* feat(acp): ACP agents in model list + request routing

* i18n(acp): add ACP agent translation keys for all 15 locales

* fix(i18n): translate ACP keys for all 15 locales + fix missing key references

- zh.ts/zh-tw.ts: proper Chinese translations (Agent not translated)
- All other locales: translated from English placeholders
- Fix acp.add → acp.addAgent, acp.disconnected → acp.notConnected

* chore: bump agent-native submodule — surface upstream HTTP errors

* feat(acp,build,codegen): comprehensive fixes for ACP integration + prod build

ACP agent integration:
- Rewrite system prompt to enforce layered design pipeline (get_design_prompt
  → design_skeleton → design_content → design_refine) for higher quality output
- Use correct PenNode field names: content for text, iconFontName for icons
- Strict JSON rules to prevent empty-key / trailing-comma / smart-quote errors
- Prefer icon_font over path icons (standalone MCP has no hooks registered)
- Auto-start MCP server before ACP session (lazy bootstrap)
- Auto-reconnect ACP on stale connection (dev server restart scenario)
- Auto-approve tool permission requests (trust model: user configured agent)
- Use type: 'http' + headers: [] for MCP server config (SDK schema requirement)
- Persist ACP connections via globalThis so they survive Vite HMR

Build / packaging:
- Place agent-native under server/node_modules for Nitro to resolve at runtime
- Copy agent_napi.node to napi/ as extraResource
- Kill detached MCP server on Electron quit (before-quit + dev SIGINT handlers)
- Capture drag-dropped filesystem path via webUtils.getPathForFile so recent
  files entries are clickable after reopening

Codegen:
- Compact JSON (no indent) + strip noise fields (id, parentId, default rotation
  /opacity/visible, layout-managed x/y) to reduce request body size by 60-70%
  so proxies don't reject with 403 'Request not allowed'

MCP batch_design robustness:
- splitOperations tracks bracket/quote balance → multi-line JSON now works
- Auto-normalize fill/stroke shorthand forms
- Collect per-line errors instead of aborting whole batch
- Repair empty keys, trailing commas, smart quotes in JSON
- Bindless I(...) form supported (auto-generates binding)

UI:
- ModelDropdown / ChatInput handle ACP model icons (Plug)
- Reset streaming state + abort controller on ACP error path
- Strip h3 JSON error wrapper so chat shows clean error messages
- ACP agent settings form + cards + connect/disconnect

* fix(types): resolve TS errors in CI typecheck

- acp-connection-manager.ts: correct relative import path (utils/ → src/types)
- ai-chat-handlers.ts: cast currentProvider to AIProviderType at design-generator callsites
- ai-chat-panel.tsx: explicitly type groups as ModelGroup[] so 'acp' string fits the widened union
- acp-agent-settings.tsx: cast window through unknown for Record lookup
- electron.d.ts: add getPathForFile to ElectronAPI declaration
- builtin-provider-presets.ts: drop now-redundant config.preset !== 'custom' check (handled by early return)
- pen-acp/client.ts: cast Writable/Readable.toWeb to typed Streams; coerce nullish agentInfo fields to undefined

---------

Co-authored-by: Rais <vdcoolzi@gmail.com>
This commit is contained in:
Kayshen Xu 2026-04-13 21:30:23 +08:00 committed by GitHub
parent 0e02281982
commit b51d069ea6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 5379 additions and 337 deletions

View file

@ -63,6 +63,12 @@ jobs:
- name: Compile MCP server
run: bun run mcp:compile
- name: Checkout openpencil-skill
uses: actions/checkout@v4
with:
repository: zseven-w/openpencil-skill
path: ../openpencil-skill
- name: Compile CLI
run: bun run cli:compile

View file

@ -43,6 +43,9 @@ jobs:
- name: Format check
run: bun run format:check
- name: Bundle CLI skill
run: bun run cli:bundle-skill
- name: Type check
run: npx tsc --noEmit

View file

@ -61,6 +61,12 @@ jobs:
fi
done
- name: Checkout openpencil-skill
uses: actions/checkout@v4
with:
repository: zseven-w/openpencil-skill
path: ../openpencil-skill
- name: Compile CLI
run: bun run cli:compile
@ -92,6 +98,18 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-engine
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-engine@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-engine
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-react
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-react@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-react
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish pen-mcp
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-mcp@${{ steps.version.outputs.version }}" version
working-directory: packages/pen-mcp

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ docs/
# Build outputs
out/
dist/
apps/cli/src/commands/skill-bundle.json
dist-ssr/
electron-dist/
dist-electron/

View file

@ -1,7 +1,22 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, `apps/desktop/CLAUDE.md`, and `apps/cli/CLAUDE.md` — loaded automatically when working in those directories.
Detailed module docs are loaded automatically when working in subdirectories:
- **`packages/CLAUDE.md`** — Package overview (all packages at a glance)
- **`packages/pen-types/CLAUDE.md`** — Type definitions for PenDocument model
- **`packages/pen-core/CLAUDE.md`** — Document tree ops, layout engine, variables, boolean ops
- **`packages/pen-engine/CLAUDE.md`** — Headless design engine (document, selection, history, viewport)
- **`packages/pen-react/CLAUDE.md`** — React UI SDK (provider, hooks, panels, canvas)
- **`packages/pen-figma/CLAUDE.md`** — Figma .fig file parser and converter
- **`packages/pen-renderer/CLAUDE.md`** — Standalone CanvasKit/Skia renderer
- **`packages/pen-mcp/CLAUDE.md`** — MCP server (tools, routes, document manager)
- **`packages/pen-ai-skills/CLAUDE.md`** — AI prompt skill engine (phase-driven loading)
- **`packages/pen-sdk/CLAUDE.md`** — Umbrella SDK (re-exports all packages)
- **`packages/agent-native/CLAUDE.md`** — Zig agent runtime (NAPI addon)
- **`apps/web/CLAUDE.md`** — Web app (canvas engine, stores, components, AI services)
- **`apps/desktop/CLAUDE.md`** — Electron desktop app (IPC, file association, auto-updater)
- **`apps/cli/CLAUDE.md`** — CLI tool (`op` commands, input methods, connection)
## Commands

View file

@ -17,6 +17,7 @@ apps/cli/
│ ├── document.ts open, save, get, selection
│ ├── export.ts export (react, html, vue, svelte, flutter, swiftui, compose, rn, css)
│ ├── import.ts import:svg, import:figma
│ ├── install.ts install, uninstall (openpencil-skill for AI agents)
│ ├── layout.ts layout, find-space
│ ├── nodes.ts insert, update, delete, move, copy, replace
│ ├── pages.ts page list/add/remove/rename/reorder/duplicate
@ -36,6 +37,7 @@ apps/cli/
- **Input methods:** Commands accepting JSON/DSL support inline string, `@filepath`, or `-` (stdin)
- **Connection:** WebSocket to running app instance (desktop or web server)
- **Launcher:** Auto-detects installed desktop app paths per platform (macOS, Windows, Linux)
- **Skill bundle:** `bun run cli:bundle-skill` pre-generates `skill-bundle.json` from `../openpencil-skill/`, embedded into the binary by esbuild. Falls back to git clone at runtime if the bundle is empty.
- **esbuild:** Compiles with `--alias:@=src` to resolve web app imports, `--external:canvas --external:paper`
- **Output:** All commands output JSON; `--pretty` flag for human-readable formatting
- **Global flags:** `--file <path>` (target .op file), `--page <id>` (target page)

View file

@ -1,12 +1,21 @@
{
"name": "@zseven-w/openpencil",
"version": "0.7.0",
"version": "0.7.1",
"description": "CLI for OpenPencil — control the design tool from your terminal",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/apps/cli",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "apps/cli"
},
"bin": {
"op": "dist/openpencil-cli.cjs"
},

View file

@ -0,0 +1,372 @@
/**
* `op install` install openpencil-skill for AI coding agents.
*
* The skill files are embedded at build time (via skill-bundle.json).
* If the bundle is empty (e.g. dev build without the skill repo), falls back to git clone.
*
* Auto-detects installed agents (Claude Code, Codex, Cursor, Gemini CLI, OpenCode)
* and installs the skill for each, or use `--target <name>` to install for one.
*/
import { execSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
symlinkSync,
readFileSync,
writeFileSync,
rmSync,
unlinkSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import bundle from './skill-bundle.json';
const REPO = 'zseven-w/openpencil-skill';
const REPO_URL = `https://github.com/${REPO}.git`;
const SKILL_NAME = 'openpencil-skill';
type Target = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode';
const ALL_TARGETS: Target[] = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
const hasBundledFiles = Object.keys(bundle.files).length > 0;
// --- Helpers ---
function which(cmd: string): string | null {
try {
return execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim() || null;
} catch {
return null;
}
}
function detectTargets(): Target[] {
const found: Target[] = [];
if (which('claude')) found.push('claude');
if (which('codex')) found.push('codex');
if (existsSync(join(homedir(), '.cursor'))) found.push('cursor');
if (which('gemini')) found.push('gemini');
if (which('opencode')) found.push('opencode');
return found;
}
function log(msg: string): void {
process.stderr.write(msg + '\n');
}
function logOk(target: string, msg: string): void {
log(`${target}: ${msg}`);
}
function logSkip(target: string, msg: string): void {
log(` - ${target}: ${msg}`);
}
function logErr(target: string, msg: string): void {
log(`${target}: ${msg}`);
}
/** Write bundled files to a destination directory. */
function writeBundleTo(dest: string, fileFilter?: (relativePath: string) => boolean): void {
const files = bundle.files as Record<string, string>;
for (const [relativePath, content] of Object.entries(files)) {
if (fileFilter && !fileFilter(relativePath)) continue;
const fullPath = join(dest, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, content);
}
}
/** Git clone/update (fallback when no bundle). */
function ensureRepo(dest: string): void {
if (existsSync(join(dest, '.git'))) {
execSync('git pull --ff-only 2>/dev/null', { cwd: dest, stdio: 'ignore' });
} else {
mkdirSync(dirname(dest), { recursive: true });
execSync(`git clone --depth 1 ${REPO_URL} "${dest}"`, { stdio: 'ignore' });
}
}
/** Write bundled files or fall back to git clone. */
function ensureSkillDir(dest: string, fileFilter?: (p: string) => boolean): void {
if (hasBundledFiles) {
mkdirSync(dest, { recursive: true });
writeBundleTo(dest, fileFilter);
} else {
ensureRepo(dest);
}
}
// --- Installers ---
function installClaude(): void {
const target = 'Claude Code';
try {
if (hasBundledFiles) {
// Write directly to the plugin cache — no GitHub access needed
const cacheDir = join(
homedir(),
'.claude',
'plugins',
'cache',
SKILL_NAME,
SKILL_NAME,
bundle.version,
);
mkdirSync(cacheDir, { recursive: true });
writeBundleTo(cacheDir);
// Update installed_plugins.json
const registryPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
const registry = existsSync(registryPath)
? JSON.parse(readFileSync(registryPath, 'utf-8'))
: { version: 2, plugins: {} };
const key = `${SKILL_NAME}@${SKILL_NAME}`;
const now = new Date().toISOString();
registry.plugins[key] = [
{
scope: 'user',
installPath: cacheDir,
version: bundle.version,
installedAt: registry.plugins[key]?.[0]?.installedAt ?? now,
lastUpdated: now,
},
];
writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
// Register marketplace entry
const marketplacesPath = join(homedir(), '.claude', 'plugins', 'known_marketplaces.json');
const marketplaces = existsSync(marketplacesPath)
? JSON.parse(readFileSync(marketplacesPath, 'utf-8'))
: {};
if (!marketplaces[SKILL_NAME]) {
marketplaces[SKILL_NAME] = {
source: { source: 'github', repo: REPO },
installLocation: join(homedir(), '.claude', 'plugins', 'marketplaces', SKILL_NAME),
lastUpdated: now,
};
writeFileSync(marketplacesPath, JSON.stringify(marketplaces, null, 2) + '\n');
}
logOk(target, `installed v${bundle.version} (bundled)`);
} else {
// Fallback: use claude CLI
try {
execSync(`claude plugin marketplace add ${REPO}`, { stdio: 'ignore' });
} catch {
/* already added */
}
execSync(`claude plugin install ${SKILL_NAME}@${SKILL_NAME}`, { stdio: 'ignore' });
logOk(target, 'installed');
}
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function installCodex(): void {
const target = 'Codex';
try {
const cloneDir = join(homedir(), '.codex', SKILL_NAME);
ensureSkillDir(cloneDir);
const skillsDir = join(homedir(), '.agents', 'skills');
mkdirSync(skillsDir, { recursive: true });
const linkPath = join(skillsDir, SKILL_NAME);
const linkTarget = join(cloneDir, 'skills');
if (!existsSync(linkPath)) {
symlinkSync(linkTarget, linkPath);
}
logOk(target, hasBundledFiles ? `installed v${bundle.version} (bundled)` : 'installed');
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function installCursor(): void {
const target = 'Cursor';
try {
const destDir = join(homedir(), '.cursor', 'plugins', SKILL_NAME);
ensureSkillDir(destDir);
logOk(target, hasBundledFiles ? `installed v${bundle.version} (bundled)` : 'installed');
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function installGemini(): void {
const target = 'Gemini CLI';
try {
const destDir = join(homedir(), '.gemini', 'extensions', SKILL_NAME);
ensureSkillDir(destDir);
logOk(target, hasBundledFiles ? `installed v${bundle.version} (bundled)` : 'installed');
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function installOpenCode(): void {
const target = 'OpenCode';
try {
const configPath = join(homedir(), '.config', 'opencode', 'opencode.json');
const pluginEntry = `${SKILL_NAME}@git+${REPO_URL}`;
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const plugins: string[] = config.plugin ?? [];
if (!plugins.some((p: string) => p.includes(SKILL_NAME))) {
plugins.push(pluginEntry);
config.plugin = plugins;
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
logOk(target, 'added to opencode.json');
} else {
logSkip(target, 'already configured');
}
} else {
mkdirSync(join(homedir(), '.config', 'opencode'), { recursive: true });
writeFileSync(configPath, JSON.stringify({ plugin: [pluginEntry] }, null, 2) + '\n');
logOk(target, 'created opencode.json');
}
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
const INSTALLERS: Record<Target, () => void> = {
claude: installClaude,
codex: installCodex,
cursor: installCursor,
gemini: installGemini,
opencode: installOpenCode,
};
// --- Uninstallers ---
function uninstallClaude(): void {
const target = 'Claude Code';
try {
if (hasBundledFiles) {
// Remove from plugin cache
const cacheParent = join(homedir(), '.claude', 'plugins', 'cache', SKILL_NAME);
if (existsSync(cacheParent)) rmSync(cacheParent, { recursive: true });
// Remove from installed_plugins.json
const registryPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
if (existsSync(registryPath)) {
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'));
delete registry.plugins[`${SKILL_NAME}@${SKILL_NAME}`];
writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
}
logOk(target, 'uninstalled');
} else {
execSync(`claude plugin uninstall ${SKILL_NAME}@${SKILL_NAME}`, { stdio: 'ignore' });
logOk(target, 'uninstalled');
}
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function uninstallCodex(): void {
const target = 'Codex';
try {
const linkPath = join(homedir(), '.agents', 'skills', SKILL_NAME);
if (existsSync(linkPath)) unlinkSync(linkPath);
const cloneDir = join(homedir(), '.codex', SKILL_NAME);
if (existsSync(cloneDir)) rmSync(cloneDir, { recursive: true });
logOk(target, 'uninstalled');
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function uninstallCursor(): void {
const target = 'Cursor';
try {
const dir = join(homedir(), '.cursor', 'plugins', SKILL_NAME);
if (existsSync(dir)) rmSync(dir, { recursive: true });
logOk(target, 'uninstalled');
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function uninstallGemini(): void {
const target = 'Gemini CLI';
try {
const dir = join(homedir(), '.gemini', 'extensions', SKILL_NAME);
if (existsSync(dir)) rmSync(dir, { recursive: true });
logOk(target, 'uninstalled');
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
function uninstallOpenCode(): void {
const target = 'OpenCode';
try {
const configPath = join(homedir(), '.config', 'opencode', 'opencode.json');
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const plugins: string[] = config.plugin ?? [];
config.plugin = plugins.filter((p: string) => !p.includes(SKILL_NAME));
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
logOk(target, 'removed from opencode.json');
} else {
logSkip(target, 'not configured');
}
} catch (e) {
logErr(target, e instanceof Error ? e.message : String(e));
}
}
const UNINSTALLERS: Record<Target, () => void> = {
claude: uninstallClaude,
codex: uninstallCodex,
cursor: uninstallCursor,
gemini: uninstallGemini,
opencode: uninstallOpenCode,
};
// --- Public commands ---
export interface InstallFlags {
target?: string;
}
function resolveTargets(targetFlag: string | undefined, mode: 'install' | 'uninstall'): Target[] {
if (targetFlag) {
const t = targetFlag.toLowerCase() as Target;
if (!ALL_TARGETS.includes(t)) {
log(`Unknown target: "${targetFlag}". Available: ${ALL_TARGETS.join(', ')}`);
process.exit(1);
}
return [t];
}
const detected = detectTargets();
if (detected.length === 0 && mode === 'install') {
log('No supported AI coding agents detected.');
log(`Supported: ${ALL_TARGETS.join(', ')}`);
log('Use --target <name> to install for a specific agent.');
process.exit(1);
}
return detected;
}
export function cmdInstall(flags: InstallFlags): void {
const targets = resolveTargets(flags.target, 'install');
log(`Installing ${SKILL_NAME} for: ${targets.join(', ')}`);
if (!hasBundledFiles) log('(no embedded bundle — using git clone fallback)');
log('');
for (const t of targets) INSTALLERS[t]();
log('');
log('Done. Restart your agent to load the skill.');
}
export function cmdUninstall(flags: InstallFlags): void {
const targets = resolveTargets(flags.target, 'uninstall');
log(`Uninstalling ${SKILL_NAME} from: ${targets.join(', ')}`);
log('');
for (const t of targets) UNINSTALLERS[t]();
log('');
log('Done.');
}

View file

@ -101,6 +101,11 @@ Import:
op import:svg <file.svg> Import SVG file
op import:figma <file.fig> Import Figma file
Skill:
op install [--target T] Install openpencil-skill for AI agents
op uninstall [--target T] Uninstall openpencil-skill
Targets: claude, codex, cursor, gemini, opencode (auto-detected if omitted)
Layout:
op layout [--parent P] [--depth N]
op find-space [--direction right|bottom|left|top]
@ -407,6 +412,18 @@ async function main(): Promise<void> {
break;
}
// --- Skill install ---
case 'install': {
const { cmdInstall } = await import('./commands/install');
cmdInstall({ target: flags.target as string | undefined });
break;
}
case 'uninstall': {
const { cmdUninstall } = await import('./commands/install');
cmdUninstall({ target: flags.target as string | undefined });
break;
}
// --- Layout ---
case 'layout': {
const { cmdLayout } = await import('./commands/layout');

View file

@ -9,14 +9,12 @@
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { build } from 'esbuild';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
import { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills';
import {
getDevServerConflictMessage,
getElectronBinaryPath,
getElectronSpawnEnv,
} from './dev-utils';
import { getElectronBinaryPath, getElectronSpawnEnv } from './dev-utils';
const DESKTOP_DIR = import.meta.dirname;
const ROOT = join(DESKTOP_DIR, '..', '..');
@ -38,55 +36,43 @@ const GENERATED_SKILL_REGISTRY = join(
async function waitForViteServer(
baseUrl: string,
vite: ChildProcess,
port: number,
timeoutMs = 30_000,
): Promise<void> {
const target = new URL(baseUrl);
const port = Number.parseInt(target.port || '80', 10);
const hosts =
target.hostname === 'localhost' ? ['127.0.0.1', '::1', 'localhost'] : [target.hostname];
const start = Date.now();
let viteExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
viteExit = { code, signal };
};
async function canConnect(host: string): Promise<boolean> {
return await new Promise((resolve) => {
const socket = new Socket();
const finish = (ok: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(ok);
};
socket.setTimeout(800);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
socket.connect(port, host);
});
}
vite.once('exit', handleExit);
while (Date.now() - start < timeoutMs) {
let baseReachable = false;
let viteClientReachable = false;
let viteClientStatus: number | null = null;
try {
const res = await fetch(baseUrl, {
signal: AbortSignal.timeout(500),
});
baseReachable = res.ok || res.status < 500;
} catch {
// server not ready yet
}
try {
const res = await fetch(`${baseUrl}/@vite/client`, {
signal: AbortSignal.timeout(500),
});
viteClientStatus = res.status;
viteClientReachable = res.ok;
if (viteClientReachable) {
for (const host of hosts) {
if (await canConnect(host)) {
vite.off('exit', handleExit);
return;
}
} catch {
// Vite client not ready yet.
}
const conflict = getDevServerConflictMessage(
{
baseReachable,
viteClientReachable,
viteClientStatus,
},
port,
);
if (conflict) {
vite.off('exit', handleExit);
throw new Error(conflict);
}
if (viteExit) {
@ -159,18 +145,47 @@ async function main(): Promise<void> {
vite.kill();
};
/** Kill the detached MCP server spawned by Nitro (survives Vite teardown). */
const stopMcpServer = () => {
const pidFile = join(tmpdir(), 'openpencil-mcp-server.pid');
const portFile = join(tmpdir(), 'openpencil-mcp-server.port');
try {
if (existsSync(pidFile)) {
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
if (Number.isFinite(pid)) {
try {
process.kill(pid, 'SIGTERM');
} catch {
/* already gone */
}
}
}
} catch {
/* ignore */
}
for (const f of [pidFile, portFile]) {
try {
unlinkSync(f);
} catch {
/* ignore */
}
}
};
// Ensure cleanup on exit
const cleanup = () => {
stopVite();
stopMcpServer();
process.exit();
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('exit', stopMcpServer);
// 2. Wait for Vite to be ready
console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`);
try {
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite, VITE_DEV_PORT);
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite);
} catch (error) {
stopVite();
throw error;

View file

@ -21,6 +21,12 @@ extraResources:
to: mcp-server.cjs
- from: apps/cli/dist/openpencil-cli.cjs
to: openpencil-cli.cjs
# agent-native is external in Vite/Nitro build — ship it alongside server/ so
# Node can resolve `@zseven-w/agent-native` at runtime. The `napi/` directory
# IS the package root (contains package.json with name "@zseven-w/agent-native"
# and agent_napi.node copied by agent-native:bundle script).
- from: packages/agent-native/napi
to: server/node_modules/@zseven-w/agent-native
mac:
category: public.app-category.graphics-design

View file

@ -9,7 +9,8 @@ import { execSync } from 'node:child_process';
import { fork, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { join, extname } from 'node:path';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
import { buildAppMenu } from './app-menu';
@ -691,9 +692,39 @@ app.on('activate', () => {
app.on('before-quit', () => {
clearUpdateTimer();
killNitroProcess();
killMcpServer();
cleanupPortFile().catch(() => {});
});
/** Kill the detached MCP server child (spawned by Nitro via mcp-server-manager). */
function killMcpServer(): void {
// PID file path matches apps/web/server/utils/mcp-server-manager.ts
const pidFile = join(tmpdir(), 'openpencil-mcp-server.pid');
const portFile = join(tmpdir(), 'openpencil-mcp-server.port');
try {
if (!existsSync(pidFile)) return;
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
if (!Number.isFinite(pid)) return;
try {
process.kill(pid, 'SIGTERM');
} catch {
/* already dead */
}
} catch {
/* ignore */
}
try {
unlinkSync(pidFile);
} catch {
/* ignore */
}
try {
unlinkSync(portFile);
} catch {
/* ignore */
}
}
/** Platform-aware Nitro process termination. */
function killNitroProcess(): void {
if (!nitroProcess) return;

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/desktop",
"version": "0.7.0",
"version": "0.7.1",
"private": true,
"type": "module"
}

View file

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron';
export type UpdaterStatus =
| 'disabled'
@ -267,6 +267,8 @@ export interface ElectronAPI {
cancelLabel: string;
}) => Promise<'save' | 'discard' | 'cancel'>;
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void;
/** Resolve the absolute filesystem path of a File object obtained from drag-and-drop. */
getPathForFile: (file: File) => string | null;
updater: {
getState: () => Promise<UpdaterState>;
checkForUpdates: () => Promise<UpdaterState>;
@ -329,6 +331,15 @@ const api: ElectronAPI = {
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) =>
ipcRenderer.send('recent-files:sync', files),
getPathForFile: (file: File) => {
try {
const p = webUtils.getPathForFile(file);
return p && p.length > 0 ? p : null;
} catch {
return null;
}
},
confirmClose: () => ipcRenderer.send('window:confirmClose'),
confirmUnsavedChanges: (payload) => ipcRenderer.invoke('dialog:confirmUnsavedChanges', payload),

View file

@ -1,10 +1,11 @@
{
"name": "@zseven-w/web",
"version": "0.7.0",
"version": "0.7.1",
"private": true,
"type": "module",
"dependencies": {
"@zseven-w/agent-native": "workspace:*",
"@zseven-w/pen-acp": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-engine": "workspace:*",

View file

@ -60,4 +60,23 @@ describe('mcp-sync-state: active client tracking', () => {
it('sendToClient returns false for unknown client', () => {
expect(sendToClient('nope', { type: 'x' })).toBe(false);
});
it('broadcasts document updates to other clients and prunes broken writers', () => {
const received: string[] = [];
registerSSEClient('client-source', { push: () => {} });
registerSSEClient('client-target', { push: (data: string) => received.push(data) });
registerSSEClient('client-broken', {
push: () => {
throw new Error('writer closed');
},
});
setSyncDocument({ version: '1.0.0', children: [] } as PenDocument, 'client-source');
expect(received).toHaveLength(1);
expect(JSON.parse(received[0]).type).toBe('document:update');
expect(isClientConnected('client-source')).toBe(true);
expect(isClientConnected('client-target')).toBe(true);
expect(isClientConnected('client-broken')).toBe(false);
});
});

View file

@ -27,8 +27,10 @@ import {
cleanup,
abortSession,
createSession,
createAcpSession,
touchSession,
type AgentSession,
type NativeAgentSession,
} from '../../utils/agent-sessions';
import {
shouldShortCircuitPlanLayout,
@ -41,6 +43,9 @@ import {
requireOpenAICompatBaseURL,
} from './provider-url';
import { startSSEKeepAlive } from '../../utils/sse-keepalive';
import { getAcpConnection } from '../../utils/acp-connection-manager';
import { getMcpServerStatus } from '../../utils/mcp-server-manager';
import { acpUpdateToSSE } from '@zseven-w/pen-acp';
const TOOL_LEVEL_MAP: Record<string, AuthLevel> = {
batch_get: 'read',
@ -84,7 +89,12 @@ const ROLE_SKILL_PHASE: Record<string, Phase> = {
const ROLE_TOOL_INSTRUCTIONS: Record<string, string> = {
designer: `You are a design team member. When asked to create designs, you MUST call the generate_design tool with a descriptive prompt. You can also use insert_node for manual node creation, batch_get and snapshot_layout to inspect the canvas, and find_empty_space to find placement locations. Always end with a short natural-language summary of what you created or changed. Never stop at tool calls only.`,
reviewer: `You are a design reviewer. Use batch_get and snapshot_layout to inspect the current canvas state. Use get_selection to see what the user has selected. Provide detailed feedback on layout, spacing, typography, and visual hierarchy. Always end with a short natural-language summary for the lead agent.`,
editor: `You are a design editor. Use batch_get and snapshot_layout to understand the current canvas. Use update_node to modify node properties, delete_node to remove elements, and insert_node to add new elements. Use find_empty_space to find placement locations. Always end with a short natural-language summary of what changed. Never stop at tool calls only.`,
editor: `You are a design editor. ALWAYS start by calling batch_get or snapshot_layout to understand the current canvas state before making changes. Match your action to user intent:
- To READ/INSPECT: use batch_get (search nodes) or snapshot_layout (spatial overview)
- To DELETE/REMOVE: use batch_get to find the node ID, then delete_node to remove it do NOT create new nodes
- To MODIFY: use update_node to change properties of existing nodes
- To ADD: use insert_node to add new elements, find_empty_space for placement
Always end with a short natural-language summary of what changed. Never stop at tool calls only.`,
researcher: `You are a design researcher. Use batch_get and snapshot_layout to analyze the current canvas state. Use find_empty_space to identify available space. Use get_selection to see what the user has selected. Provide analysis and recommendations. Always end with a short natural-language summary for the lead agent.`,
};
@ -165,7 +175,7 @@ interface AgentBody {
sessionId: string;
messages: Array<{ role: string; content: string }>;
systemPrompt: string;
providerType: 'anthropic' | 'openai-compat';
providerType: 'anthropic' | 'openai-compat' | 'acp';
apiKey: string;
model: string;
baseURL?: string;
@ -178,6 +188,8 @@ interface AgentBody {
concurrency?: number;
designMdContent?: string;
hasVariables?: boolean;
acpAgentId?: string;
acpConfig?: import('../../../src/types/agent-settings').AcpAgentConfig;
}
/** Map Zig event JSON to client SSE format.
@ -239,9 +251,28 @@ function zigEventToSSE(raw: string): string {
break;
case 'result':
if (data.is_error) {
const parts: string[] = [`Agent error: ${data.subtype ?? 'unknown'}`];
if (data.result) parts.push(String(data.result));
// Provider-captured upstream errors (HTTP body from anthropic /
// openai_compat). Often a JSON envelope — show the inner message
// when we can parse it; otherwise dump the raw body.
if (Array.isArray(data.errors)) {
for (const raw of data.errors as unknown[]) {
const text = typeof raw === 'string' ? raw : JSON.stringify(raw);
let pretty = text;
try {
const obj = JSON.parse(text);
const inner = obj?.error?.message ?? obj?.message ?? obj?.error?.type;
if (typeof inner === 'string' && inner.length > 0) pretty = inner;
} catch {
/* not JSON — keep raw */
}
parts.push(pretty.length > 600 ? pretty.slice(0, 600) + '…' : pretty);
}
}
mapped = {
type: 'error',
message: `Agent error: ${data.subtype ?? 'unknown'}${data.result ? ' — ' + data.result : ''}`,
message: parts.join(' — '),
fatal: true,
};
} else {
@ -268,9 +299,10 @@ function zigEventToSSE(raw: string): string {
return `event: ${mapped.type}\ndata: ${JSON.stringify(mapped)}\n\n`;
}
/** Run a delegated member asynchronously — does NOT block the caller. */
/** Run a delegated member asynchronously does NOT block the caller.
* Only called for native agent sessions (team mode). */
async function runDelegateMember(
session: AgentSession,
session: NativeAgentSession,
body: AgentBody,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
@ -400,6 +432,14 @@ export default defineEventHandler(async (event) => {
const toolName = session.toolNames.get(body.toolCallId);
updateLayoutSessionState(session, toolName, body.result);
// ACP sessions: tools are executed by the agent via MCP, not client-side.
// Just acknowledge the result and return.
if (session.type === 'acp') {
session.lastActivity = Date.now();
session.toolNames.delete(body.toolCallId);
return { ok: true };
}
const resultJson = JSON.stringify(body.result);
// Per-toolCallId routing: check if this tool belongs to a member
const memberId = session.toolOwners?.get(body.toolCallId);
@ -436,6 +476,211 @@ export default defineEventHandler(async (event) => {
// ── Start agent loop (SSE stream) ──────────────────────────
const body = await readBody<AgentBody>(event);
// ── ACP Agent path ──────────────────────────────────────────
if (body?.providerType === 'acp' && body.acpAgentId) {
let conn = getAcpConnection(body.acpAgentId as string);
// If connection missing (e.g. dev server restart) but we have the config,
// attempt to reconnect transparently using the config sent by the client.
if (!conn && (body as any).acpConfig) {
console.log(`[acp] connection missing, auto-reconnecting ${body.acpAgentId}`);
const { connectAcp } = await import('../../utils/acp-connection-manager');
const result = await connectAcp(body.acpAgentId as string, (body as any).acpConfig);
if (!result.connected) {
throw createError({
statusCode: 400,
message: `ACP agent auto-reconnect failed: ${result.error ?? 'unknown error'}`,
});
}
conn = getAcpConnection(body.acpAgentId as string);
}
if (!conn) {
throw createError({ statusCode: 400, message: 'ACP agent not connected' });
}
// Create ACP session. ACP agents need the OpenPencil MCP server to do
// anything useful (without it they just call Terminal/Skill tools that
// don't work here). Require it to be running — the user starts it from
// the MCP settings tab.
// NOTE: claude-agent-acp expects `type: 'http' | 'sse'` (not `transport`).
const mcpStatus = getMcpServerStatus();
if (!mcpStatus.running || !mcpStatus.port) {
throw createError({
statusCode: 400,
message:
'MCP server is not running. Open Settings → MCP and click "Start" to enable ACP agents to access OpenPencil design tools.',
});
}
const mcpServers = [
{
name: 'openpencil',
type: 'http' as const,
url: `http://127.0.0.1:${mcpStatus.port}/mcp`,
headers: [] as Array<{ name: string; value: string }>,
},
];
console.log(
`[acp] newSession mcpServers=${JSON.stringify(mcpServers)} (mcpStatus: running=${mcpStatus.running}, port=${mcpStatus.port})`,
);
// Override the agent's default system prompt (via _meta) to prevent it
// from using the openpencil-skill (which is designed for CLI scenarios
// where the agent runs `op` commands in terminals). Inside OpenPencil,
// the agent should use MCP tools directly.
const acpSystemPrompt = [
'You are an AI design assistant integrated inside the OpenPencil vector design tool.',
'The user sees a live canvas; your job is to produce polished, visually refined UI designs on it.',
'You have direct access to OpenPencil\'s document via the "openpencil" MCP server.',
'',
'## Tool Usage Rules',
'- NEVER use Bash/Terminal to run `op` CLI commands. The CLI is not available here.',
'- NEVER use the openpencil-skill or Skill tool. They are for a different context.',
'- DO use the `mcp__openpencil__*` tools to operate on the canvas.',
'- After finishing, provide a brief one-sentence summary of what was done.',
'',
'## REQUIRED Workflow for Creating New Designs',
'Always follow this three-phase pipeline (it produces higher quality than ad-hoc insert calls):',
'',
"1. **Load the design guide (ONCE)**: Call `get_design_prompt` to receive OpenPencil's design principles, node schema details, role system, color/typography tokens, and layout patterns. Read it carefully — it defines the canonical shapes and defaults.",
'2. **Build skeleton**: Call `design_skeleton` with a high-level description. This creates the structural frames (sections, layout containers) with correct auto-layout.',
'3. **Fill content**: Call `design_content` once per section from step 2, adding the concrete children (buttons, inputs, text, icons).',
'4. **Refine** (optional): Call `design_refine` on the root to apply final polish (consistent spacing, role-based styling).',
'',
'Only fall back to `batch_design` or `insert_node` when the user explicitly asks for small/surgical edits rather than a new page.',
'',
'## Modifying Existing Designs',
'- Call `snapshot_layout` first to see the current tree.',
'- Use `update_node` for property changes, `move_node` for reparenting, `delete_node` to remove.',
'- Prefer one `batch_design` over many individual calls when making multiple related changes.',
'',
'## Canonical Node Shapes (IMPORTANT)',
'The canvas will render nothing useful if you use the wrong `type` or shape. Use these:',
'',
'- **Frame** (container with layout): `{"type": "frame", "name": "X", "width": 375, "height": 812, "layout": "vertical", "gap": 16, "padding": [24, 24, 24, 24], "fill": [{"type": "solid", "color": "#FFFFFF"}], "children": [...]}`',
'- **Text** (field is `content` NOT `text`): `{"type": "text", "name": "Title", "content": "Welcome", "fontSize": 24, "fontWeight": 700, "fill": [{"type": "solid", "color": "#111827"}]}`',
'- **Icon** (use `icon_font` NOT `icon`, field is `iconFontName` NOT `iconName`): `{"type": "icon_font", "name": "Lock Icon", "iconFontName": "lock", "width": 20, "height": 20, "fill": [{"type": "solid", "color": "#6B7280"}]}`. Common iconFontName values (Lucide): `mail`, `lock`, `eye`, `eye-off`, `chrome`, `apple`, `message-circle`, `x`, `arrow-right`, `search`, `heart`, `star`, `check`, `plus`, `bell`, `home`, `user`, `settings`.',
'- **Rectangle**: `{"type": "rectangle", "width": 100, "height": 100, "cornerRadius": 8, "fill": [{"type": "solid", "color": "#3B82F6"}]}`',
'- **Button** (frame + text child): use `"role": "cta-button"` on the frame so role resolution applies standard button styling.',
'',
'## STRICT JSON Rules',
'When emitting node JSON inside tool arguments, produce strictly valid JSON:',
'- Every property MUST have BOTH a key and value. NEVER emit `": 50` or `: 50` with no key.',
'- Every key MUST be a double-quoted non-empty string.',
'- `fill` is ALWAYS an array: `"fill": [{"type": "solid", "color": "#hex"}]`.',
'- `stroke` is `{"thickness": 1, "fill": [{"type": "solid", "color": "#hex"}]}`. NEVER `{"thickness": 1, "color": "#hex"}`.',
'- NO trailing commas, NO comments, use straight `"` not smart quotes.',
'- Layout on frames: `"layout": "vertical" | "horizontal" | "none"`, `"gap": number`, `"padding": [top, right, bottom, left]`, `"alignItems": "start" | "center" | "end"`, `"justifyContent": "start" | "center" | "end" | "space-between"`.',
'- Width/height: number OR `"fill_container"` OR `"fit_content"`.',
'- Before calling the tool, mentally verify the JSON is valid. Every key has a value; every value has a key.',
].join('\n');
const { sessionId: acpSessionId } = await conn.connection.newSession({
cwd: process.cwd(),
mcpServers,
_meta: { systemPrompt: acpSystemPrompt },
} as Parameters<typeof conn.connection.newSession>[0]);
const clientSessionId = body.sessionId as string;
agentSessions.set(
clientSessionId,
createAcpSession({
acpSessionId,
acpAgentId: body.acpAgentId as string,
connection: conn.connection,
}),
);
// Build prompt from last user message
const lastMsg = ((body.messages as any[]) ?? []).at(-1);
const promptText =
typeof lastMsg?.content === 'string'
? lastMsg.content
: JSON.stringify(lastMsg?.content ?? '');
// Wire session/update notifications into SSE stream
const updateTarget = new EventTarget();
conn.sessionUpdateEmitter = updateTarget;
const encoder = new TextEncoder();
let streamClosed = false;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (chunk: Uint8Array) => {
if (streamClosed) return;
try {
controller.enqueue(chunk);
} catch (err) {
// Controller may have closed mid-notification (e.g. client disconnect,
// idle timeout). Mark closed and stop enqueuing to avoid noise.
streamClosed = true;
}
};
const onUpdate = (e: Event) => {
const notification = (e as CustomEvent).detail;
const sse = acpUpdateToSSE(notification);
if (sse) safeEnqueue(encoder.encode(sse));
};
updateTarget.addEventListener('update', onUpdate);
// Keep-alive: prevent Bun's 10s idle timeout from killing the stream
// during long MCP tool calls (e.g. snapshot_layout → insert_node chain).
const keepAlive = startSSEKeepAlive(
() => safeEnqueue(encoder.encode(`: keepalive\n\n`)),
5000,
);
try {
console.log(`[acp] prompt() start for ${acpSessionId}`);
const promptResult = await conn.connection.prompt({
sessionId: acpSessionId,
prompt: [{ type: 'text', text: promptText }],
});
console.log(
`[acp] prompt() returned, stopReason=${(promptResult as { stopReason?: string })?.stopReason ?? 'unknown'}, streamClosed=${streamClosed}`,
);
safeEnqueue(
encoder.encode(
`event: done\ndata: ${JSON.stringify({ type: 'done', totalTurns: 1 })}\n\n`,
),
);
} catch (err) {
console.error(`[acp] prompt() threw:`, err);
safeEnqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({
type: 'error',
message: `ACP error: ${err instanceof Error ? err.message : String(err)}`,
fatal: true,
})}\n\n`,
),
);
} finally {
console.log(`[acp] prompt() finally, closing stream`);
clearInterval(keepAlive);
updateTarget.removeEventListener('update', onUpdate);
conn.sessionUpdateEmitter = null;
agentSessions.delete(clientSessionId);
if (!streamClosed) {
streamClosed = true;
try {
controller.close();
} catch {
/* already closed */
}
}
}
},
});
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
return new Response(stream);
}
if (
!body?.sessionId ||
!body.messages ||
@ -587,8 +832,9 @@ export default defineEventHandler(async (event) => {
}
});
// providerType is narrowed: 'acp' returned early above
const provider = createProviderHandle(
body.providerType,
body.providerType as 'anthropic' | 'openai-compat',
body.apiKey,
body.model,
normalizedBaseURL,
@ -776,7 +1022,7 @@ export default defineEventHandler(async (event) => {
// Create provider (use member model or lead's)
const mProvider = createProviderHandle(
body.providerType,
body.providerType as 'anthropic' | 'openai-compat',
body.apiKey,
memberModel ?? body.model,
normalizedBaseURL,

View file

@ -0,0 +1,30 @@
import { defineEventHandler, readBody, createError } from 'h3';
import { connectAcp, disconnectAcp } from '../../utils/acp-connection-manager';
import type { AcpAgentConfig } from '../../../src/types/agent-settings';
export default defineEventHandler(async (event) => {
const body = await readBody<{
action: 'connect' | 'disconnect';
agentId: string;
config?: AcpAgentConfig;
}>(event);
if (!body?.agentId || !body.action) {
throw createError({ statusCode: 400, message: 'Missing: agentId, action' });
}
if (body.action === 'connect') {
if (!body.config) {
throw createError({ statusCode: 400, message: 'Missing: config (required for connect)' });
}
const result = await connectAcp(body.agentId, body.config);
return result;
}
if (body.action === 'disconnect') {
await disconnectAcp(body.agentId);
return { connected: false };
}
throw createError({ statusCode: 400, message: `Unknown action: ${body.action}` });
});

View file

@ -1,5 +1,6 @@
import { defineEventHandler, readBody, createError } from 'h3';
import { defineEventHandler, readBody, createError, getRequestHeader, setResponseStatus } from 'h3';
import { setSyncDocument } from '../../utils/mcp-sync-state';
import { serverLog } from '../../utils/server-logger';
import type { PenDocument } from '../../../src/types/pen';
interface PostBody {
@ -7,16 +8,138 @@ interface PostBody {
sourceClientId?: string;
}
interface DocumentStats {
nodeCount: number;
imageCount: number;
dataUrlImageCount: number;
dataUrlChars: number;
}
function collectDocumentStats(doc: PenDocument): DocumentStats {
const stats: DocumentStats = {
nodeCount: 0,
imageCount: 0,
dataUrlImageCount: 0,
dataUrlChars: 0,
};
const visit = (nodes?: unknown): void => {
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
stats.nodeCount++;
const typedNode = node as {
type?: string;
src?: string;
children?: unknown;
};
if (typedNode.type === 'image') {
stats.imageCount++;
if (typeof typedNode.src === 'string' && typedNode.src.startsWith('data:')) {
stats.dataUrlImageCount++;
stats.dataUrlChars += typedNode.src.length;
}
}
visit(typedNode.children);
}
};
visit(doc.children);
if (Array.isArray(doc.pages)) {
for (const page of doc.pages) {
visit(page?.children);
}
}
return stats;
}
function formatBytes(bytes: number | null): string {
if (bytes == null || Number.isNaN(bytes)) return 'unknown';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
return `${(bytes / (1024 * 1024)).toFixed(2)}MiB`;
}
function isConnectionClosedError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybeError = error as { name?: string; message?: string; cause?: unknown };
const message = maybeError.message ?? '';
const causeMessage =
typeof maybeError.cause === 'object' && maybeError.cause
? String((maybeError.cause as { message?: string }).message ?? '')
: '';
return (
maybeError.name === 'AbortError' ||
/connection was closed/i.test(message) ||
/connection was closed/i.test(causeMessage) ||
/abort/i.test(message)
);
}
/** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */
export default defineEventHandler(async (event) => {
const body = await readBody<PostBody>(event);
if (!body?.document) {
throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' });
const startedAt = Date.now();
const contentLengthHeader = getRequestHeader(event, 'content-length');
const bodyBytesHeader = getRequestHeader(event, 'x-openpencil-body-bytes');
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: bodyBytesHeader
? Number.parseInt(bodyBytesHeader, 10)
: null;
const sourceClientIdHeader = getRequestHeader(event, 'x-openpencil-client-id') ?? 'unknown';
let phase = 'readBody';
try {
const body = await readBody<PostBody>(event);
if (!body?.document) {
throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' });
}
const doc = body.document;
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
throw createError({ statusCode: 400, statusMessage: 'Invalid document format' });
}
const stats = collectDocumentStats(doc);
phase = 'setSyncDocument';
const version = setSyncDocument(doc, body.sourceClientId);
const elapsedMs = Date.now() - startedAt;
serverLog.info(
`[mcp-document] ok version=${version} sourceClientId=${body.sourceClientId ?? sourceClientIdHeader} ` +
`contentLength=${formatBytes(contentLength)} nodes=${stats.nodeCount} images=${stats.imageCount} ` +
`dataUrlImages=${stats.dataUrlImageCount} dataUrlChars=${stats.dataUrlChars} elapsedMs=${elapsedMs}`,
);
return { ok: true, version };
} catch (error) {
const elapsedMs = Date.now() - startedAt;
const message = error instanceof Error ? error.message : String(error);
if (isConnectionClosedError(error)) {
serverLog.warn(
`[mcp-document] connection-closed phase=${phase} contentLength=${formatBytes(contentLength)} ` +
`sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`,
);
// The client already closed the request while Nitro was still reading it.
// Returning a soft status keeps expected sync churn out of the 500 logs.
setResponseStatus(event, 202, 'Client closed request during MCP document sync');
return {
ok: false,
aborted: true,
phase,
};
}
serverLog.error(
`[mcp-document] failed phase=${phase} contentLength=${formatBytes(contentLength)} ` +
`sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`,
);
throw error;
}
const doc = body.document;
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
throw createError({ statusCode: 400, statusMessage: 'Invalid document format' });
}
const version = setSyncDocument(doc, body.sourceClientId);
return { ok: true, version };
});

View file

@ -0,0 +1,79 @@
import { connectAcpAgent, disconnectAcpAgent } from '@zseven-w/pen-acp';
import type { AcpConnectionState, AcpConnectResult } from '@zseven-w/pen-acp';
import type { AcpAgentConfig } from '../../src/types/agent-settings';
// Use globalThis so connections survive Vite HMR / Nitro module reloads.
// Without this, re-evaluating this module wipes the Map and existing UI
// sessions get "ACP agent not connected" errors until reconnecting.
const globalStore = globalThis as unknown as {
__acpConnections?: Map<string, AcpConnectionState>;
};
const connections: Map<string, AcpConnectionState> =
globalStore.__acpConnections ?? (globalStore.__acpConnections = new Map());
export function getAcpConnection(agentId: string): AcpConnectionState | undefined {
const conn = connections.get(agentId);
console.log(
`[acp] getAcpConnection(${agentId}) → ${conn ? 'found' : 'MISSING'}, total connections: ${connections.size}, keys: [${Array.from(connections.keys()).join(', ')}]`,
);
return conn;
}
export async function connectAcp(
agentId: string,
config: AcpAgentConfig,
): Promise<AcpConnectResult> {
// Server-side safety: reject local mode in hosted production web deployments
// where spawning arbitrary processes is a security risk. Allow it when:
// - Running under Electron (process.versions.electron set)
// - Running in dev mode (NODE_ENV !== 'production')
// - OPENPENCIL_ALLOW_LOCAL_ACP=1 (explicit opt-in for self-hosted non-Electron)
// Note: Nitro server runs in a Vite worker in dev, so process.versions.electron
// is undefined even during electron:dev — hence the NODE_ENV check.
const isElectron = !!process.versions.electron;
const isDev = process.env.NODE_ENV !== 'production';
const isAllowed = process.env.OPENPENCIL_ALLOW_LOCAL_ACP === '1';
if (config.connectionType === 'local' && !isElectron && !isDev && !isAllowed) {
return {
connected: false,
error:
'Local agents are only available in the desktop app. Set OPENPENCIL_ALLOW_LOCAL_ACP=1 to enable in self-hosted deployments.',
};
}
// Disconnect existing if any
if (connections.has(agentId)) {
await disconnectAcp(agentId);
}
try {
console.log(
`[acp] connecting ${agentId} (${config.connectionType}: ${config.command ?? config.url})`,
);
const state = await connectAcpAgent(config);
connections.set(agentId, state);
console.log(`[acp] connected ${agentId}, total connections: ${connections.size}`);
return { connected: true, agentInfo: state.agentInfo };
} catch (err) {
console.error(`[acp] connect failed ${agentId}:`, err);
return {
connected: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function disconnectAcp(agentId: string): Promise<void> {
const state = connections.get(agentId);
if (state) {
disconnectAcpAgent(state);
connections.delete(agentId);
}
}
export function cleanupAllAcp(): void {
for (const [id, state] of connections) {
disconnectAcpAgent(state);
connections.delete(id);
}
}

View file

@ -5,6 +5,7 @@ import type {
ToolRegistryHandle,
TeamHandle,
} from '@zseven-w/agent-native';
import type { ClientSideConnection } from '@agentclientprotocol/sdk';
import type { LayoutPhase } from './agent-tool-guard';
import {
abortEngine,
@ -16,7 +17,8 @@ import {
destroyTeam,
} from '@zseven-w/agent-native';
export interface AgentSession {
export interface NativeAgentSession {
type: 'native';
engine?: QueryEngineHandle;
team?: TeamHandle;
iter?: IteratorHandle;
@ -36,20 +38,36 @@ export interface AgentSession {
layoutRootId: string | null;
}
/** Create a session with required defaults. */
export interface AcpAgentSession {
type: 'acp';
acpSessionId: string;
acpAgentId: string;
connection: ClientSideConnection;
createdAt: number;
lastActivity: number;
toolNames: Map<string, string>;
toolOwners: Map<string, string>;
layoutPhase: LayoutPhase;
layoutRootId: string | null;
}
export type AgentSession = NativeAgentSession | AcpAgentSession;
/** Create a native session with required defaults. */
export function createSession(
fields: Omit<
AgentSession,
'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId'
NativeAgentSession,
'type' | 'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId'
> &
Partial<
Pick<
AgentSession,
NativeAgentSession,
'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId'
>
>,
): AgentSession {
): NativeAgentSession {
return {
type: 'native',
...fields,
toolOwners: fields.toolOwners ?? new Map(),
toolNames: fields.toolNames ?? new Map(),
@ -59,6 +77,24 @@ export function createSession(
};
}
/** Create an ACP session with required defaults. */
export function createAcpSession(fields: {
acpSessionId: string;
acpAgentId: string;
connection: ClientSideConnection;
}): AcpAgentSession {
return {
type: 'acp',
...fields,
createdAt: Date.now(),
lastActivity: Date.now(),
toolNames: new Map(),
toolOwners: new Map(),
layoutPhase: 'idle',
layoutRootId: null,
};
}
export const agentSessions = new Map<string, AgentSession>();
/** Mark a session as active so long-running external tool callbacks are not expired. */
@ -68,6 +104,7 @@ export function touchSession(session: Pick<AgentSession, 'lastActivity'>, now =
/** Idempotent cleanup — nullifies handles after destroying to prevent double-free. */
export function cleanup(session: AgentSession): void {
if (session.type === 'acp') return; // ACP connections managed by acp-connection-manager
if (session.iter) {
destroyIterator(session.iter);
session.iter = undefined;
@ -100,6 +137,12 @@ export function cleanup(session: AgentSession): void {
/** Abort a session — makes pending nextEvent resolve null. */
export function abortSession(session: AgentSession): void {
if (session.type === 'acp') {
try {
(session.connection as any).cancel?.({ sessionId: session.acpSessionId });
} catch {}
return;
}
if (session.team) abortTeam(session.team);
else if (session.engine) abortEngine(session.engine);
}

View file

@ -65,13 +65,20 @@ export function unregisterSSEClient(id: string): void {
}
function broadcast(payload: Record<string, unknown>, excludeClientId?: string): void {
const data = JSON.stringify(payload);
const recipients: SSEClient[] = [];
for (const [id, client] of clients) {
if (id === excludeClientId) continue;
recipients.push(client);
}
if (recipients.length === 0) return;
const data = JSON.stringify(payload);
for (const client of recipients) {
try {
client.writer.push(data);
} catch {
clients.delete(id);
clients.delete(client.id);
}
}
}

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { createEmptyDocument } from '@/stores/document-tree-utils';
import { useCanvasStore } from '@/stores/canvas-store';
@ -70,8 +70,11 @@ function resetStores() {
}
describe('SkiaInteractionManager continuous interaction commits', () => {
it('defers resize store writes until mouseup', () => {
beforeEach(() => {
resetStores();
});
it('defers resize store writes until mouseup', () => {
let node: any = {
id: 'path-1',
type: 'path',
@ -143,7 +146,6 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
});
it('defers rotate store writes until mouseup', () => {
resetStores();
let node: any = {
id: 'rect-1',
type: 'rectangle',
@ -199,7 +201,6 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
});
it('defers arc handle store writes until mouseup', () => {
resetStores();
let node: any = {
id: 'ellipse-1',
type: 'ellipse',
@ -253,4 +254,101 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
expect(updateNodeCalls).toHaveLength(1);
expect(updateNodeCalls[0]?.[1]).toHaveProperty('innerRadius');
});
it('keeps an image-backed node selected instead of auto-selecting its parent frame', () => {
const frame = {
id: 'frame-1',
type: 'frame',
x: 0,
y: 0,
width: 300,
height: 300,
children: [],
} as PenNode;
const imageBackedRect = {
id: 'child-1',
type: 'rectangle',
x: 10,
y: 20,
width: 120,
height: 80,
fill: [{ type: 'image', url: 'memory://image.png' }],
} as PenNode;
useDocumentStore.setState({
getNodeById: (id: string) => {
if (id === frame.id) return frame;
if (id === imageBackedRect.id) return imageBackedRect;
return undefined;
},
getParentOf: (id: string) => (id === imageBackedRect.id ? frame : null),
isDescendantOf: () => false,
} as any);
const engine = createEngineStub([
{
node: { ...imageBackedRect },
absX: 10,
absY: 20,
absW: 120,
absH: 80,
},
]);
(engine as any).spatialIndex.hitTest = () => [{ node: imageBackedRect } as any];
const manager = new SkiaInteractionManager(
{ current: engine as any },
createCanvasStub(),
() => {},
) as any;
manager.handleSelectMouseDown(
{ shiftKey: false } as MouseEvent,
{ x: 20, y: 30 },
engine as any,
);
expect(useCanvasStore.getState().selection.selectedIds).toEqual(['child-1']);
expect(useCanvasStore.getState().selection.activeId).toBe('child-1');
});
it('moves clip rects together with dragged render nodes', () => {
const node = {
id: 'frame-1',
type: 'frame',
x: 50,
y: 60,
width: 200,
height: 120,
children: [],
} as PenNode;
const renderNode = {
node: { ...node },
absX: 50,
absY: 60,
absW: 200,
absH: 120,
clipRect: { x: 45, y: 55, w: 210, h: 130, rx: 8 },
};
const engine = createEngineStub([renderNode]);
const manager = new SkiaInteractionManager(
{ current: engine as any },
createCanvasStub(),
() => {},
) as any;
manager.isDragging = true;
manager.dragNodeIds = ['frame-1'];
manager.dragStartSceneX = 0;
manager.dragStartSceneY = 0;
manager.handleDragMove({ x: 20, y: 15 }, engine as any);
expect(renderNode.absX).toBe(70);
expect(renderNode.absY).toBe(75);
expect(renderNode.clipRect).toMatchObject({ x: 65, y: 70, w: 210, h: 130, rx: 8 });
expect(engine.rebuildCount).toBeGreaterThan(0);
expect(engine.dirtyCount).toBeGreaterThan(0);
});
});

View file

@ -79,6 +79,13 @@ export function toolToCursor(tool: ToolType): string {
}
}
function hasImageVisual(node: PenNode | undefined): boolean {
if (!node) return false;
if (node.type === 'image') return true;
if (!('fill' in node)) return false;
return Array.isArray(node.fill) && node.fill.some((fill: any) => fill?.type === 'image');
}
/**
* Encapsulates all canvas mouse/keyboard interaction state and handlers.
* Extracted from SkiaCanvas to keep the component focused on lifecycle and rendering.
@ -371,8 +378,13 @@ export class SkiaInteractionManager {
if (isChildOfSelected) {
// Don't change selection
} else if (!currentSelection.includes(nodeId)) {
const clickedNode = docStore.getNodeById(nodeId);
const parent = docStore.getParentOf(nodeId);
if (parent && (parent.type === 'frame' || parent.type === 'group')) {
if (
!hasImageVisual(clickedNode) &&
parent &&
(parent.type === 'frame' || parent.type === 'group')
) {
const grandparent = docStore.getParentOf(parent.id);
if (!grandparent || grandparent.type === 'frame') {
nodeId = parent.id;
@ -906,6 +918,13 @@ export class SkiaInteractionManager {
if (this.dragAllIds!.has(rn.node.id)) {
rn.absX += incrDx;
rn.absY += incrDy;
if (rn.clipRect) {
rn.clipRect = {
...rn.clipRect,
x: rn.clipRect.x + incrDx,
y: rn.clipRect.y + incrDy,
};
}
rn.node = { ...rn.node, x: rn.absX, y: rn.absY };
}
}

View file

@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { nanoid } from 'nanoid';
import i18n from '@/i18n';
import type { AgentEvent } from '@/types/agent';
import type { AIProviderType } from '@/types/agent-settings';
function decodeAgentEvent(raw: string): AgentEvent | null {
const eventMatch = raw.match(/^event:\s*(\S+)/);
@ -41,6 +42,7 @@ import type { ToolCallBlockData } from '@/components/panels/tool-call-block';
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config';
import { classifyIntent } from './ai-chat-intent-classifier';
import { buildContextString } from './ai-chat-context-builder';
import { detectAgentIntent, getCrudToolDefs } from '@/services/ai/agent-tools';
// Re-export for any external consumers
export { buildContextString } from './ai-chat-context-builder';
@ -74,22 +76,40 @@ RULE 3: Do NOT call generate_design more than once unless the user asks for a ne
FORBIDDEN: Do not output JSON, code blocks, or node definitions directly. Always use generate_design instead.`;
/** Lightweight prompt for CRUD operations — no design skills, just tool usage. */
const AGENT_TOOL_INSTRUCTIONS_CRUD = `You are a design editor. Use tools to inspect, modify, insert, and delete elements on the canvas.
WORKFLOW:
1. Use snapshot_layout or batch_get FIRST to see the tree structure and find node IDs.
2. Use the appropriate tool: insert_node to add, update_node to modify, delete_node to remove, move_node to reparent.
3. When inserting, use "after" parameter with a sibling ID to place the new node in the correct position.
4. After each operation, write 1-2 sentences summarizing what changed.
INSERT_NODE GUIDE always include complete node data with children:
- Button example: {"type":"frame","name":"My Button","width":"fill_container","height":50,"cornerRadius":8,"fill":[{"type":"solid","color":"#1877F2"}],"layout":"horizontal","gap":8,"alignItems":"center","justifyContent":"center","children":[{"type":"icon_font","name":"Icon","iconName":"facebook","width":20,"height":20,"fill":[{"type":"solid","color":"#FFFFFF"}]},{"type":"text","name":"Label","text":"Continue with Facebook","fontSize":15,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}]}
- Text example: {"type":"text","name":"Title","text":"Hello","fontSize":24,"fontWeight":700,"fill":[{"type":"solid","color":"#1A1A2E"}]}
- When adding next to a similar element, use batch_get to read that element's full data first, then create matching structure.
Focus on the specific operation the user requested.`;
/** Agent instructions for lead agents coordinating a team. */
const AGENT_TOOL_INSTRUCTIONS_TEAM = `You are a design lead coordinating a team.
Do not create the design directly in this mode. Analyze the request, delegate the work to team members, then summarize the outcome for the user.`;
/**
* Build the agent system prompt based on provider type.
* Builtin providers get direct design instructions (plan_layout + batch_insert).
* CLI providers get generate_design tool instructions (orchestrator pipeline).
* Build the agent system prompt based on provider type and detected intent.
* CRUD intents (read/update/delete) get a lightweight prompt with no design skills.
* Design intents get the full design generation pipeline.
*/
function buildAgentSystemPrompt(
_userMessage: string,
userMessage: string,
isBuiltin: boolean,
teamMode: boolean,
): string {
if (teamMode) return AGENT_TOOL_INSTRUCTIONS_TEAM;
const intent = detectAgentIntent(userMessage);
if (intent === 'crud') return AGENT_TOOL_INSTRUCTIONS_CRUD;
return isBuiltin ? AGENT_TOOL_INSTRUCTIONS_BUILTIN : AGENT_TOOL_INSTRUCTIONS_CLI;
}
@ -128,7 +148,7 @@ async function* parseAgentSSE(
/** Provider config for the agent pipeline */
interface AgentProviderConfig {
providerType: 'anthropic' | 'openai-compat';
providerType: 'anthropic' | 'openai-compat' | 'acp';
apiKey: string;
model: string;
baseURL?: string;
@ -204,7 +224,8 @@ async function runAgentStream(
const { useAIStore: concurrencyStore } = await import('@/stores/ai-store');
const concurrency = concurrencyStore.getState().concurrency;
const teamMode = concurrency > 1;
const toolDefs = getDesignToolDefs();
const intent = detectAgentIntent(lastUserMsg);
const toolDefs = intent === 'crud' ? getCrudToolDefs() : getDesignToolDefs();
const systemPrompt = buildAgentSystemPrompt(lastUserMsg, isBuiltin, teamMode) + context;
const agentBody: Record<string, unknown> = {
@ -226,6 +247,15 @@ async function runAgentStream(
...(hasVariables ? { hasVariables } : {}),
};
// ACP: add agentId + config to the request body (config enables server-side
// auto-reconnect if the in-memory connection was lost due to dev server restart).
if (providerConfig.providerType === 'acp') {
const agentId = providerConfig.model.slice(4);
const acpConfig = useAgentSettingsStore.getState().acpAgents.find((a) => a.id === agentId);
(agentBody as any).acpAgentId = agentId;
if (acpConfig) (agentBody as any).acpConfig = acpConfig;
}
const response = await fetch('/api/ai/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -235,7 +265,17 @@ async function runAgentStream(
if (!response.ok || !response.body) {
const errText = await response.text().catch(() => 'Unknown error');
throw new Error(`Agent request failed: ${errText}`);
// h3 errors come as JSON: { message, error, status, ... } — extract just the message.
let errorMessage = errText;
try {
const parsed = JSON.parse(errText);
if (parsed && typeof parsed === 'object' && typeof parsed.message === 'string') {
errorMessage = parsed.message;
}
} catch {
/* not JSON — use raw text */
}
throw new Error(errorMessage);
}
const reader = response.body.getReader();
@ -254,6 +294,10 @@ async function runAgentStream(
let identityPool: AgentIdentity[] = [];
let nextIdentityIdx = 0;
const memberIdentities = new Map<string, AgentIdentity>();
// Track the most recent failed tool call so terminal `error_server` events
// (which carry no detail from the Zig engine) can surface what actually broke.
const toolNames = new Map<string, string>();
let lastToolError: { name: string; message: string } | null = null;
try {
for await (const evt of parseAgentSSE(reader, abortController.signal)) {
@ -293,6 +337,7 @@ async function runAgentStream(
// Skip internal team coordination tools — they are resolved by agent-team, not the client
if (evt.level === 'orchestrate') break;
toolNames.set(evt.id, evt.name);
executor
.execute(evt as Extract<AgentEvent, { type: 'tool_call' }>)
.then((result) => {
@ -303,12 +348,19 @@ async function runAgentStream(
result: result ?? undefined,
});
}
if (result && result.success === false) {
lastToolError = {
name: evt.name,
message: String(result.error ?? 'unknown error'),
};
}
})
.catch((err) => {
useAIStore.getState().updateToolCallBlock(evt.id, {
status: 'error',
result: { success: false, error: String(err) },
});
lastToolError = { name: evt.name, message: String(err) };
});
break;
}
@ -319,6 +371,12 @@ async function runAgentStream(
status: evt.result.success ? 'done' : 'error',
result: evt.result,
});
if (!evt.result.success) {
lastToolError = {
name: toolNames.get(evt.id) ?? 'tool',
message: String((evt.result as { error?: unknown }).error ?? 'unknown error'),
};
}
break;
}
@ -356,7 +414,15 @@ async function runAgentStream(
}
case 'error': {
accumulated += `\n\n**Error:** ${evt.message}`;
// Terminal `Agent error: error_server` events from the Zig engine
// carry no detail. Fall back to the last failed tool call so the
// user sees the actual cause (e.g. an upstream 529 surfaced via
// a tool error, or a runtime exception inside the design pipeline).
let detail = '';
if (lastToolError && /^Agent error:/i.test(evt.message)) {
detail = `\n> Last tool failure (\`${lastToolError.name}\`): ${lastToolError.message}`;
}
accumulated += `\n\n**Error:** ${evt.message}${detail}`;
updateLastMessage(accumulated);
renderer.finish();
if (evt.fatal) return stripThinkTags(accumulated);
@ -544,6 +610,64 @@ export function useChatHandlers() {
return;
}
// -----------------------------------------------------------------------
// ACP AGENT MODE — routes to ACP agent via runAgentStream()
// -----------------------------------------------------------------------
if (model.startsWith('acp:')) {
const agentId = model.slice(4);
const { acpAgents } = useAgentSettingsStore.getState();
const acpConfig = acpAgents.find((a: any) => a.id === agentId);
if (!acpConfig) {
useAIStore.setState((s) => {
const msgs = [...s.messages];
const last = msgs[msgs.length - 1];
if (last) {
last.content = 'ACP agent not found. Please check your settings.';
last.isStreaming = false;
}
return { messages: msgs };
});
return;
}
useAIStore.getState().clearToolCallBlocks();
try {
await runAgentStream(
assistantMsg.id,
{
providerType: 'acp',
apiKey: 'acp',
model: model,
},
abortController,
);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
useAIStore.setState((s) => {
const msgs = [...s.messages];
const last = msgs[msgs.length - 1];
if (last) {
last.content = last.content
? `${last.content}\n\n**Error:** ${errorMsg}`
: `**Error:** ${errorMsg}`;
last.isStreaming = false;
}
return { messages: msgs };
});
} finally {
// Always clear streaming state — both on success and on error.
useAIStore.getState().setAbortController(null);
setStreaming(false);
useAIStore.setState((s) => {
const msgs = [...s.messages];
const last = msgs[msgs.length - 1];
if (last?.isStreaming) last.isStreaming = false;
return { messages: msgs };
});
}
return;
}
// -----------------------------------------------------------------------
// STANDARD MODE — design/chat pipeline (external CLI providers)
// -----------------------------------------------------------------------
@ -596,7 +720,7 @@ export function useChatHandlers() {
themes: modDoc.themes,
designMd: useDesignMdStore.getState().designMd,
model,
provider: currentProvider,
provider: currentProvider as AIProviderType | undefined,
},
abortController.signal,
);
@ -612,7 +736,7 @@ export function useChatHandlers() {
{
prompt: fullUserMessage,
model,
provider: currentProvider,
provider: currentProvider as AIProviderType | undefined,
concurrency,
context: {
canvasSize: { width: 1200, height: 800 },

View file

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react';
import { Send, ChevronUp, Paperclip, X, Square, Key } from 'lucide-react';
import { Send, ChevronUp, Paperclip, X, Square, Key, Plug } from 'lucide-react';
import { nanoid } from 'nanoid';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@ -157,25 +157,17 @@ export function AIChatInput({ input, setInput, onSend }: AIChatInputProps) {
>
{(() => {
if (model.startsWith('builtin:')) {
const currentGroup = modelGroups.find((g) =>
g.models.some((m) => m.value === model),
);
if (currentGroup) {
const ProvIcon = PROVIDER_ICON[currentGroup.provider];
return ProvIcon ? (
<ProvIcon className="w-3.5 h-3.5 shrink-0" />
) : (
<Key size={12} className="shrink-0 text-muted-foreground" />
);
}
return <Key size={12} className="shrink-0 text-muted-foreground" />;
}
if (model.startsWith('acp:')) {
return <Plug size={12} className="shrink-0 text-muted-foreground" />;
}
const currentProvider = modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider;
if (currentProvider) {
const ProvIcon = PROVIDER_ICON[currentProvider];
return <ProvIcon className="w-3.5 h-3.5 shrink-0" />;
const ProvIcon = PROVIDER_ICON[currentProvider as keyof typeof PROVIDER_ICON];
return ProvIcon ? <ProvIcon className="w-3.5 h-3.5 shrink-0" /> : null;
}
return null;
})()}

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Check, Search, X, Zap, Key } from 'lucide-react';
import { Check, Search, X, Zap, Key, Plug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useAIStore } from '@/stores/ai-store';
@ -119,17 +119,20 @@ export function ModelDropdown({ open, onClose }: { open: boolean; onClose: () =>
}
return filtered.map((group, groupIdx) => {
const GIcon = PROVIDER_ICON[group.provider];
const GIcon = PROVIDER_ICON[group.provider as AIProviderType];
const groupKey = `${group.provider}-${group.providerName}-${groupIdx}`;
const isBuiltinGroup = group.models.some((m) => m.value.startsWith('builtin:'));
const isAcpGroup = group.models.some((m) => m.value.startsWith('acp:'));
return (
<div key={groupKey}>
<div className="flex items-center gap-1.5 px-3 pt-2.5 pb-1">
{isBuiltinGroup ? (
<Key size={10} className="text-muted-foreground shrink-0" />
) : (
) : isAcpGroup ? (
<Plug size={10} className="text-muted-foreground shrink-0" />
) : GIcon ? (
<GIcon className="w-3 h-3 text-muted-foreground" />
)}
) : null}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{group.providerName}
</span>

View file

@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button';
import { useAIStore } from '@/stores/ai-store';
import type { PanelCorner } from '@/stores/ai-store';
import { useAgentSettingsStore } from '@/stores/agent-settings-store';
import type { AIProviderType } from '@/types/agent-settings';
import type { AIProviderType, ModelGroup } from '@/types/agent-settings';
import { useChatHandlers } from './ai-chat-handlers';
import { resolveNextModel } from './ai-chat-model-selector';
import { AIChatMessageList } from './ai-chat-message-list';
@ -108,6 +108,8 @@ export default function AIChatPanel() {
const providers = useAgentSettingsStore((s) => s.providers);
const builtinProviders = useAgentSettingsStore((s) => s.builtinProviders);
const providersHydrated = useAgentSettingsStore((s) => s.isHydrated);
const acpAgents = useAgentSettingsStore((s) => s.acpAgents);
const acpConnectionStatus = useAgentSettingsStore((s) => s.acpConnectionStatus);
const { input, setInput, handleSend } = useChatHandlers();
const canUseModel = !isLoadingModels && availableModels.length > 0;
@ -137,7 +139,7 @@ export default function AIChatPanel() {
(p) => providers[p].isConnected && (providers[p].models?.length ?? 0) > 0,
);
const groups = connectedProviders.map((p) => ({
const groups: ModelGroup[] = connectedProviders.map((p) => ({
provider: p,
providerName: providerNames[p],
models: providers[p].models,
@ -162,6 +164,25 @@ export default function AIChatPanel() {
});
}
// ACP agents
for (const agent of acpAgents) {
const status = acpConnectionStatus[agent.id];
if (status?.isConnected) {
groups.push({
provider: 'acp',
providerName: `${agent.displayName} (ACP)`,
models: [
{
value: `acp:${agent.id}`,
displayName: agent.displayName,
description: status.agentInfo?.title ?? 'ACP Agent',
provider: 'acp',
},
],
});
}
}
if (groups.length > 0) {
const flat = groups.flatMap((g) =>
g.models.map((m) => ({
@ -184,7 +205,7 @@ export default function AIChatPanel() {
setModelGroups([]);
setAvailableModels([]);
setLoadingModels(false);
}, [providers, builtinProviders, providersHydrated]); // eslint-disable-line react-hooks/exhaustive-deps
}, [providers, builtinProviders, providersHydrated, acpAgents, acpConnectionStatus]); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-expand when streaming starts while minimized
useEffect(() => {

View file

@ -0,0 +1,461 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Sparkles, Globe, Terminal, Pencil, Trash2, Plug, Unplug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useAgentSettingsStore } from '@/stores/agent-settings-store';
import type { AcpAgentConfig } from '@/types/agent-settings';
/* ---------- Shared field wrapper ---------- */
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 pl-0.5">
{label}
</label>
{children}
</div>
);
}
const inputClass =
'w-full h-8 px-2.5 text-[12px] bg-background text-foreground rounded-md border border-input focus:border-ring focus:ring-1 focus:ring-ring/20 outline-none transition-all';
/* ---------- Helpers ---------- */
/** Check whether we are running inside the Electron shell */
function isElectron(): boolean {
return (
typeof window !== 'undefined' && !!(window as unknown as Record<string, unknown>).electronAPI
);
}
/** Parse KEY=VALUE lines into a record */
function parseEnvText(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || !trimmed.includes('=')) continue;
const idx = trimmed.indexOf('=');
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (key) env[key] = value;
}
return env;
}
/** Serialize a record to KEY=VALUE lines */
function envToText(env?: Record<string, string>): string {
if (!env) return '';
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join('\n');
}
/* ---------- AcpAgentForm ---------- */
export function AcpAgentForm({
initial,
onSave,
onCancel,
}: {
initial?: AcpAgentConfig;
onSave: (data: Omit<AcpAgentConfig, 'id'>) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const electron = isElectron();
const [displayName, setDisplayName] = useState(initial?.displayName ?? '');
const [connectionType, setConnectionType] = useState<'local' | 'remote'>(
initial?.connectionType ?? (electron ? 'local' : 'remote'),
);
const [command, setCommand] = useState(initial?.command ?? '');
const [args, setArgs] = useState(initial?.args?.join(', ') ?? '');
const [envText, setEnvText] = useState(envToText(initial?.env));
const [url, setUrl] = useState(initial?.url ?? '');
const canSave =
displayName.trim().length > 0 &&
(connectionType === 'local' ? command.trim().length > 0 : url.trim().length > 0);
const handleSave = useCallback(() => {
const base = {
displayName: displayName.trim(),
connectionType,
enabled: initial?.enabled ?? true,
};
if (connectionType === 'local') {
const parsedArgs = args
.split(',')
.map((a) => a.trim())
.filter(Boolean);
const parsedEnv = parseEnvText(envText);
onSave({
...base,
command: command.trim(),
args: parsedArgs.length > 0 ? parsedArgs : undefined,
env: Object.keys(parsedEnv).length > 0 ? parsedEnv : undefined,
});
} else {
onSave({
...base,
url: url.trim(),
});
}
}, [displayName, connectionType, command, args, envText, url, initial?.enabled, onSave]);
return (
<div className="rounded-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2.5 bg-secondary/30 border-b border-border">
<div className="h-5 w-5 rounded-md bg-primary/10 flex items-center justify-center">
<Sparkles size={11} className="text-primary" />
</div>
<span className="text-[12px] font-medium text-foreground">
{initial ? t('common.save') : t('acp.addAgent')}
</span>
</div>
<div className="p-4 space-y-3.5">
{/* Display Name */}
<Field label={t('acp.displayName')}>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('acp.displayNamePlaceholder')}
className={inputClass}
/>
</Field>
{/* Connection Type toggle */}
<Field label={t('acp.connectionType')}>
<div className="flex gap-1">
{electron && (
<button
type="button"
onClick={() => setConnectionType('local')}
className={cn(
'flex-1 h-7 text-[11px] rounded-md border transition-all font-medium flex items-center justify-center gap-1',
connectionType === 'local'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-input hover:bg-accent',
)}
>
<Terminal size={11} />
{t('acp.local')}
</button>
)}
<button
type="button"
onClick={() => setConnectionType('remote')}
className={cn(
'flex-1 h-7 text-[11px] rounded-md border transition-all font-medium flex items-center justify-center gap-1',
connectionType === 'remote'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-input hover:bg-accent',
)}
>
<Globe size={11} />
{t('acp.remote')}
</button>
</div>
</Field>
{/* Local-mode fields */}
{connectionType === 'local' && (
<>
<Field label={t('acp.command')}>
<input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t('acp.commandPlaceholder')}
className={cn(inputClass, 'font-mono')}
/>
</Field>
<Field label={t('acp.args')}>
<input
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={t('acp.argsPlaceholder')}
className={cn(inputClass, 'font-mono text-[11px]')}
/>
</Field>
<Field label={t('acp.env')}>
<textarea
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={t('acp.envPlaceholder')}
rows={3}
className={cn(
inputClass,
'h-auto py-1.5 resize-none font-mono text-[11px] leading-relaxed',
)}
/>
</Field>
</>
)}
{/* Remote-mode fields */}
{connectionType === 'remote' && (
<Field label={t('acp.url')}>
<div className="relative">
<Globe
size={12}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/40"
/>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('acp.urlPlaceholder')}
className={cn(inputClass, 'pl-7 font-mono text-[11px]')}
/>
</div>
</Field>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1.5 border-t border-border/50">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
className="h-7 px-3 text-[11px] rounded-md"
>
{t('common.cancel')}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!canSave}
className="h-7 px-4 text-[11px] rounded-md"
>
{initial ? t('common.save') : t('acp.addAgent')}
</Button>
</div>
</div>
</div>
);
}
/* ---------- AcpAgentCard ---------- */
function AcpAgentCard({ agent }: { agent: AcpAgentConfig }) {
const { t } = useTranslation();
const update = useAgentSettingsStore((s) => s.updateAcpAgent);
const remove = useAgentSettingsStore((s) => s.removeAcpAgent);
const setStatus = useAgentSettingsStore((s) => s.setAcpConnectionStatus);
const persist = useAgentSettingsStore((s) => s.persist);
const connectionStatus = useAgentSettingsStore((s) => s.acpConnectionStatus[agent.id]);
const [editing, setEditing] = useState(false);
const [connecting, setConnecting] = useState(false);
const isConnected = connectionStatus?.isConnected ?? false;
const agentInfo = connectionStatus?.agentInfo;
const handleConnect = useCallback(async () => {
setConnecting(true);
try {
const res = await fetch('/api/ai/connect-acp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'connect', agentId: agent.id, config: agent }),
});
const data = res.ok ? await res.json() : null;
if (data?.connected) {
setStatus(agent.id, {
isConnected: true,
agentInfo: data.agentInfo,
});
} else {
const msg = data?.error ?? `HTTP ${res.status}`;
console.error('[acp] connect failed:', msg);
alert(`ACP connect failed: ${msg}`);
setStatus(agent.id, { isConnected: false });
}
} catch (err) {
console.error('[acp] connect error:', err);
setStatus(agent.id, { isConnected: false });
} finally {
setConnecting(false);
}
}, [agent, setStatus]);
const handleDisconnect = useCallback(async () => {
setConnecting(true);
try {
await fetch('/api/ai/connect-acp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'disconnect', agentId: agent.id }),
});
setStatus(agent.id, { isConnected: false });
} catch {
// ignore
} finally {
setConnecting(false);
}
}, [agent.id, setStatus]);
const handleRemove = useCallback(() => {
remove(agent.id);
persist();
}, [agent.id, remove, persist]);
const handleSave = useCallback(
(data: Omit<AcpAgentConfig, 'id'>) => {
update(agent.id, data);
persist();
setEditing(false);
},
[agent.id, update, persist],
);
if (editing) {
return <AcpAgentForm initial={agent} onSave={handleSave} onCancel={() => setEditing(false)} />;
}
return (
<div className="group">
<div
className={cn(
'flex items-center gap-3 px-3.5 py-2.5 rounded-lg border transition-colors',
agent.enabled
? 'bg-secondary/30 border-border'
: 'border-transparent hover:bg-secondary/20',
)}
>
{/* Icon */}
<div
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors',
agent.enabled
? 'bg-foreground/8 text-foreground'
: 'bg-secondary text-muted-foreground',
)}
>
{agent.connectionType === 'local' ? <Terminal size={18} /> : <Globe size={18} />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-medium text-foreground leading-tight">
{agent.displayName}
</span>
<span
className={cn(
'text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded-full',
agent.connectionType === 'local'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
)}
>
{agent.connectionType === 'local' ? t('acp.local') : t('acp.remote')}
</span>
</div>
{/* Connection status */}
<div className="flex items-center gap-1 mt-0.5">
<span
className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
isConnected ? 'bg-green-500' : 'bg-muted-foreground/30',
)}
/>
<span className="text-[11px] text-muted-foreground leading-tight">
{isConnected
? agentInfo
? `${agentInfo.name}${agentInfo.version ? ` v${agentInfo.version}` : ''}`
: t('acp.connected')
: t('acp.notConnected')}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={isConnected ? handleDisconnect : handleConnect}
disabled={connecting}
className="h-6 px-2 text-[10px] rounded-md gap-1"
>
{isConnected ? (
<>
<Unplug size={11} />
{t('acp.disconnect')}
</>
) : (
<>
<Plug size={11} />
{t('acp.connect')}
</>
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setEditing(true)}
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil size={11} />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={handleRemove}
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
>
<Trash2 size={11} />
</Button>
</div>
</div>
</div>
);
}
/* ---------- AcpAgentSection ---------- */
export function AcpAgentSection() {
const { t } = useTranslation();
const acpAgents = useAgentSettingsStore((s) => s.acpAgents);
const addAcpAgent = useAgentSettingsStore((s) => s.addAcpAgent);
const persist = useAgentSettingsStore((s) => s.persist);
const [showForm, setShowForm] = useState(false);
const handleAdd = useCallback(
(data: Omit<AcpAgentConfig, 'id'>) => {
addAcpAgent(data);
persist();
setShowForm(false);
},
[addAcpAgent, persist],
);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t('acp.title')}</h3>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="text-[11px] text-primary hover:text-primary/80 flex items-center gap-1 transition-colors font-medium"
>
<Plus size={12} /> {t('acp.addAgent')}
</button>
)}
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('acp.description')}</p>
{showForm && <AcpAgentForm onSave={handleAdd} onCancel={() => setShowForm(false)} />}
{acpAgents.map((agent) => (
<AcpAgentCard key={agent.id} agent={agent} />
))}
{!showForm && acpAgents.length === 0 && (
<div className="text-center py-6 text-[11px] text-muted-foreground">{t('acp.empty')}</div>
)}
</div>
);
}

View file

@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useAgentSettingsStore } from '@/stores/agent-settings-store';
import { BuiltinProvidersSection } from './builtin-provider-settings';
import { AcpAgentSection } from './acp-agent-settings';
import type { AIProviderType, GroupedModel } from '@/types/agent-settings';
import ClaudeLogo from '@/components/icons/claude-logo';
import OpenAILogo from '@/components/icons/openai-logo';
@ -312,6 +313,9 @@ export function ProvidersTab() {
<div className="mb-6">
<BuiltinProvidersSection />
</div>
<div className="mb-6">
<AcpAgentSection />
</div>
<h3 className="text-[15px] font-semibold text-foreground mb-4">{t('settings.agents')}</h3>
<div className="space-y-1">
<ProviderCard type="anthropic" />

View file

@ -75,7 +75,12 @@ export function useFileDrop() {
const result = await parseDroppedFile(file);
if (!result) return;
useDocumentStore.getState().loadDocument(result.doc, result.fileName);
// In Electron, resolve the absolute filesystem path so the recent files
// entry is clickable later. In a plain browser this returns null.
const filePath =
(typeof window !== 'undefined' && window.electronAPI?.getPathForFile?.(file)) || null;
useDocumentStore.getState().loadDocument(result.doc, result.fileName, null, filePath);
// Let the canvas sync, then zoom to fit
const { zoomToFitContent } = await import('@/canvas/skia-engine-ref');

View file

@ -7,6 +7,9 @@ const PUSH_DEBOUNCE_MS = 2000;
const SELECTION_DEBOUNCE_MS = 300;
const RECONNECT_DELAY_MS = 3000;
const MAX_RECONNECT_ATTEMPTS = 3;
const SYNC_MAX_BODY_BYTES = 2 * 1024 * 1024;
let oversizeSyncWarned = false;
async function handleScreenshotRequest(
req: {
@ -92,13 +95,38 @@ function getBaseUrl(): string {
return window.location.origin;
}
function pushDocumentToServer(clientId: string | null) {
async function pushDocumentToServer(clientId: string | null) {
const doc = useDocumentStore.getState().document;
fetch(`${getBaseUrl()}/api/mcp/document`, {
const body = JSON.stringify({ document: doc, sourceClientId: clientId });
const bodyBytes = new TextEncoder().encode(body).byteLength;
if (bodyBytes > SYNC_MAX_BODY_BYTES) {
if (!oversizeSyncWarned) {
oversizeSyncWarned = true;
console.warn(
`[mcp-sync] Skip oversized document push: ${(bodyBytes / (1024 * 1024)).toFixed(
2,
)}MiB > ${(SYNC_MAX_BODY_BYTES / (1024 * 1024)).toFixed(2)}MiB`,
);
}
return;
}
oversizeSyncWarned = false;
await fetch(`${getBaseUrl()}/api/mcp/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document: doc, sourceClientId: clientId }),
}).catch(() => {});
headers: {
'Content-Type': 'application/json',
'x-openpencil-client-id': clientId ?? 'renderer:unknown',
'x-openpencil-body-bytes': String(bodyBytes),
},
// Keep smaller requests alive through page transitions and HMR churn.
...(bodyBytes <= 60_000 ? { keepalive: true } : {}),
// Large local sync payloads need a wider timeout budget than the fetch default.
signal: AbortSignal.timeout(30_000),
body,
});
}
/**
@ -109,6 +137,9 @@ function pushDocumentToServer(clientId: string | null) {
export function useMcpSync() {
const clientIdRef = useRef<string | null>(null);
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pushInFlightRef = useRef(false);
const pushQueuedRef = useRef(false);
const queuedClientIdRef = useRef<string | null>(null);
// Skip debounce pushes briefly after applying an external document.
// Use a timestamp instead of a boolean so cascading setState calls
// (e.g. canvas sync page switch handler) are also suppressed.
@ -121,6 +152,30 @@ export function useMcpSync() {
let disposed = false;
let reconnectAttempts = 0;
async function flushDocumentPush(clientId: string | null) {
if (disposed) return;
if (pushInFlightRef.current) {
pushQueuedRef.current = true;
queuedClientIdRef.current = clientId;
return;
}
pushInFlightRef.current = true;
try {
await pushDocumentToServer(clientId);
} catch {
// MCP sync is a best-effort enhancement and should not interrupt editing.
} finally {
pushInFlightRef.current = false;
if (pushQueuedRef.current && !disposed) {
const nextClientId = queuedClientIdRef.current;
pushQueuedRef.current = false;
queuedClientIdRef.current = null;
void flushDocumentPush(nextClientId);
}
}
}
// ---- Focus / visibility ping: keep lastActiveClientId accurate ----
const sendActivePing = () => {
if (typeof document !== 'undefined' && document.hidden) return;
@ -150,7 +205,7 @@ export function useMcpSync() {
if (data.type === 'client:id') {
clientIdRef.current = data.clientId;
// Push current document so MCP can read it immediately
pushDocumentToServer(data.clientId);
void flushDocumentPush(data.clientId);
// Announce this tab as the active one
sendActivePing();
} else if (data.type === 'document:update' || data.type === 'document:init') {
@ -203,17 +258,20 @@ export function useMcpSync() {
// On loadDocument/newDocument (isDirty transitions to false), push
// immediately so the server cache is replaced without waiting 2s.
const unsubDoc = useDocumentStore.subscribe((state, prevState) => {
const documentChanged = state.document !== prevState.document;
const dirtyChanged = state.isDirty !== prevState.isDirty;
if (!documentChanged && !dirtyChanged) return;
if (Date.now() < skipPushUntilRef.current) return;
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
const isLoadEvent = !state.isDirty && prevState.isDirty !== state.isDirty;
if (isLoadEvent) {
pushDocumentToServer(clientIdRef.current);
void flushDocumentPush(clientIdRef.current);
return;
}
pushTimerRef.current = setTimeout(() => {
pushDocumentToServer(clientIdRef.current);
void flushDocumentPush(clientIdRef.current);
}, PUSH_DEBOUNCE_MS);
});

View file

@ -776,6 +776,30 @@ const de: TranslationKeys = {
'builtin.teamDesignModel': 'Design-Modell',
'builtin.teamSelectModel': 'Keins (Einzelagent)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Externe ACP-kompatible Agent verbinden.',
'acp.addAgent': 'Agent hinzufügen',
'acp.empty': 'Keine ACP Agent konfiguriert.',
'acp.displayName': 'Anzeigename',
'acp.displayNamePlaceholder': 'z.B. Mein Design-Agent',
'acp.connectionType': 'Verbindungstyp',
'acp.local': 'Lokal',
'acp.remote': 'Remote',
'acp.command': 'Befehl',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Argumente',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Umgebungsvariablen',
'acp.envPlaceholder': 'KEY=VALUE (eine pro Zeile)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Verbunden',
'acp.notConnected': 'Nicht verbunden',
'acp.connect': 'Verbinden',
'acp.disconnect': 'Trennen',
'acp.localDesktopOnly': 'Lokale Agent sind nur in der Desktop-App verfügbar.',
// ── Figma Import ──
'figma.title': 'Aus Figma importieren',
'figma.dropFile': '.fig-Datei hier ablegen',

View file

@ -765,6 +765,30 @@ const en = {
'builtin.teamDesignModel': 'Design Model',
'builtin.teamSelectModel': 'None (single agent)',
// ── ACP Agents ──
'acp.title': 'ACP Agents',
'acp.description': 'Connect external ACP-compatible agents.',
'acp.addAgent': 'Add Agent',
'acp.empty': 'No ACP agents configured yet.',
'acp.displayName': 'Display Name',
'acp.displayNamePlaceholder': 'e.g. My Design Agent',
'acp.connectionType': 'Connection Type',
'acp.local': 'Local',
'acp.remote': 'Remote',
'acp.command': 'Command',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Arguments',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Environment Variables',
'acp.envPlaceholder': 'KEY=VALUE (one per line)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Connected',
'acp.notConnected': 'Not connected',
'acp.connect': 'Connect',
'acp.disconnect': 'Disconnect',
'acp.localDesktopOnly': 'Local agents are only available in the desktop app.',
// ── Figma Import ──
'figma.title': 'Import from Figma',
'figma.dropFile': 'Drop a .fig file here',

View file

@ -777,6 +777,31 @@ const es: TranslationKeys = {
'builtin.teamDesignModel': 'Modelo de diseño',
'builtin.teamSelectModel': 'Ninguno (agente único)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Conectar Agent compatibles con ACP.',
'acp.addAgent': 'Agregar Agent',
'acp.empty': 'No hay ACP Agent configurados.',
'acp.displayName': 'Nombre',
'acp.displayNamePlaceholder': 'ej. Mi Agent de diseño',
'acp.connectionType': 'Tipo de conexión',
'acp.local': 'Local',
'acp.remote': 'Remoto',
'acp.command': 'Comando',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Argumentos',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Variables de entorno',
'acp.envPlaceholder': 'KEY=VALUE (una por línea)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Conectado',
'acp.notConnected': 'No conectado',
'acp.connect': 'Conectar',
'acp.disconnect': 'Desconectar',
'acp.localDesktopOnly':
'Los Agent locales solo están disponibles en la aplicación de escritorio.',
// ── Figma Import ──
'figma.title': 'Importar desde Figma',
'figma.dropFile': 'Suelte un archivo .fig aquí',

View file

@ -781,6 +781,31 @@ const fr: TranslationKeys = {
'builtin.teamDesignModel': 'Modèle de design',
'builtin.teamSelectModel': 'Aucun (agent unique)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Connecter des Agent ACP externes.',
'acp.addAgent': 'Ajouter un Agent',
'acp.empty': 'Aucun ACP Agent configuré.',
'acp.displayName': 'Nom affiché',
'acp.displayNamePlaceholder': 'ex. Mon Agent de design',
'acp.connectionType': 'Type de connexion',
'acp.local': 'Local',
'acp.remote': 'Distant',
'acp.command': 'Commande',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Arguments',
'acp.argsPlaceholder': '--stdio',
'acp.env': "Variables d'environnement",
'acp.envPlaceholder': 'KEY=VALUE (une par ligne)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Connecté',
'acp.notConnected': 'Non connecté',
'acp.connect': 'Connecter',
'acp.disconnect': 'Déconnecter',
'acp.localDesktopOnly':
"Les Agent locaux sont disponibles uniquement dans l'application de bureau.",
// ── Figma Import ──
'figma.title': 'Importer depuis Figma',
'figma.dropFile': 'Déposez un fichier .fig ici',

View file

@ -759,6 +759,30 @@ const hi: TranslationKeys = {
'builtin.teamDesignModel': 'डिज़ाइन मॉडल',
'builtin.teamSelectModel': 'कोई नहीं (एकल एजेंट)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'बाहरी ACP-संगत Agent कनेक्ट करें।',
'acp.addAgent': 'Agent जोड़ें',
'acp.empty': 'कोई ACP Agent कॉन्फ़िगर नहीं किया गया।',
'acp.displayName': 'प्रदर्शन नाम',
'acp.displayNamePlaceholder': 'उदा. मेरा डिज़ाइन Agent',
'acp.connectionType': 'कनेक्शन प्रकार',
'acp.local': 'स्थानीय',
'acp.remote': 'रिमोट',
'acp.command': 'कमांड',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'आर्ग्यूमेंट',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'पर्यावरण चर',
'acp.envPlaceholder': 'KEY=VALUE (प्रति पंक्ति एक)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'कनेक्टेड',
'acp.notConnected': 'कनेक्ट नहीं',
'acp.connect': 'कनेक्ट करें',
'acp.disconnect': 'डिस्कनेक्ट करें',
'acp.localDesktopOnly': 'स्थानीय Agent केवल डेस्कटॉप ऐप में उपलब्ध हैं।',
// ── Figma Import ──
'figma.title': 'Figma से आयात करें',
'figma.dropFile': 'यहाँ .fig फ़ाइल ड्रॉप करें',

View file

@ -768,6 +768,30 @@ const id: TranslationKeys = {
'builtin.teamDesignModel': 'Model Desain',
'builtin.teamSelectModel': 'Tidak ada (agen tunggal)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Hubungkan Agent yang kompatibel dengan ACP.',
'acp.addAgent': 'Tambah Agent',
'acp.empty': 'Belum ada ACP Agent yang dikonfigurasi.',
'acp.displayName': 'Nama Tampilan',
'acp.displayNamePlaceholder': 'mis. Agent Desain Saya',
'acp.connectionType': 'Jenis Koneksi',
'acp.local': 'Lokal',
'acp.remote': 'Jarak Jauh',
'acp.command': 'Perintah',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Argumen',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Variabel Lingkungan',
'acp.envPlaceholder': 'KEY=VALUE (satu per baris)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Terhubung',
'acp.notConnected': 'Tidak terhubung',
'acp.connect': 'Hubungkan',
'acp.disconnect': 'Putuskan',
'acp.localDesktopOnly': 'Agent lokal hanya tersedia di aplikasi desktop.',
// ── Figma Import ──
'figma.title': 'Impor dari Figma',
'figma.dropFile': 'Letakkan file .fig di sini',

View file

@ -770,6 +770,30 @@ const ja: TranslationKeys = {
'builtin.teamDesignModel': 'デザインモデル',
'builtin.teamSelectModel': 'なし(シングルエージェント)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': '外部の ACP 互換 Agent を接続します。',
'acp.addAgent': 'Agent を追加',
'acp.empty': 'ACP Agent はまだ設定されていません。',
'acp.displayName': '表示名',
'acp.displayNamePlaceholder': '例:デザイン Agent',
'acp.connectionType': '接続タイプ',
'acp.local': 'ローカル',
'acp.remote': 'リモート',
'acp.command': 'コマンド',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': '引数',
'acp.argsPlaceholder': '--stdio',
'acp.env': '環境変数',
'acp.envPlaceholder': 'KEY=VALUE1行に1つ',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': '接続済み',
'acp.notConnected': '未接続',
'acp.connect': '接続',
'acp.disconnect': '切断',
'acp.localDesktopOnly': 'ローカル Agent はデスクトップアプリでのみ使用できます。',
// ── Figma Import ──
'figma.title': 'Figma からインポート',
'figma.dropFile': '.fig ファイルをここにドロップ',

View file

@ -763,6 +763,30 @@ const ko: TranslationKeys = {
'builtin.teamDesignModel': '디자인 모델',
'builtin.teamSelectModel': '없음 (단일 에이전트)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': '외부 ACP 호환 Agent를 연결합니다.',
'acp.addAgent': 'Agent 추가',
'acp.empty': '구성된 ACP Agent가 없습니다.',
'acp.displayName': '표시 이름',
'acp.displayNamePlaceholder': '예: 내 디자인 Agent',
'acp.connectionType': '연결 유형',
'acp.local': '로컬',
'acp.remote': '원격',
'acp.command': '명령어',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': '인수',
'acp.argsPlaceholder': '--stdio',
'acp.env': '환경 변수',
'acp.envPlaceholder': 'KEY=VALUE (줄당 하나)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': '연결됨',
'acp.notConnected': '연결 안됨',
'acp.connect': '연결',
'acp.disconnect': '연결 해제',
'acp.localDesktopOnly': '로컬 Agent는 데스크톱 앱에서만 사용할 수 있습니다.',
// ── Figma Import ──
'figma.title': 'Figma에서 가져오기',
'figma.dropFile': '.fig 파일을 여기에 놓으세요',

View file

@ -774,6 +774,30 @@ const pt: TranslationKeys = {
'builtin.teamDesignModel': 'Modelo de design',
'builtin.teamSelectModel': 'Nenhum (agente único)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Conectar Agent compatíveis com ACP.',
'acp.addAgent': 'Adicionar Agent',
'acp.empty': 'Nenhum ACP Agent configurado.',
'acp.displayName': 'Nome de exibição',
'acp.displayNamePlaceholder': 'ex. Meu Agent de design',
'acp.connectionType': 'Tipo de conexão',
'acp.local': 'Local',
'acp.remote': 'Remoto',
'acp.command': 'Comando',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Argumentos',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Variáveis de ambiente',
'acp.envPlaceholder': 'KEY=VALUE (uma por linha)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Conectado',
'acp.notConnected': 'Não conectado',
'acp.connect': 'Conectar',
'acp.disconnect': 'Desconectar',
'acp.localDesktopOnly': 'Agent locais estão disponíveis apenas no aplicativo desktop.',
// ── Figma Import ──
'figma.title': 'Importar do Figma',
'figma.dropFile': 'Solte um arquivo .fig aqui',

View file

@ -771,6 +771,30 @@ const ru: TranslationKeys = {
'builtin.teamDesignModel': 'Модель дизайна',
'builtin.teamSelectModel': 'Нет (один агент)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Подключение внешних ACP-совместимых Agent.',
'acp.addAgent': 'Добавить Agent',
'acp.empty': 'Нет настроенных ACP Agent.',
'acp.displayName': 'Отображаемое имя',
'acp.displayNamePlaceholder': 'напр. Мой Agent дизайна',
'acp.connectionType': 'Тип подключения',
'acp.local': 'Локальный',
'acp.remote': 'Удалённый',
'acp.command': 'Команда',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Аргументы',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Переменные среды',
'acp.envPlaceholder': 'KEY=VALUE (по одной на строку)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Подключён',
'acp.notConnected': 'Не подключён',
'acp.connect': 'Подключить',
'acp.disconnect': 'Отключить',
'acp.localDesktopOnly': 'Локальные Agent доступны только в настольном приложении.',
// ── Figma Import ──
'figma.title': 'Импорт из Figma',
'figma.dropFile': 'Перетащите файл .fig сюда',

View file

@ -755,6 +755,30 @@ const th: TranslationKeys = {
'builtin.teamDesignModel': 'โมเดลออกแบบ',
'builtin.teamSelectModel': 'ไม่มี (เอเจนต์เดี่ยว)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'เชื่อมต่อ Agent ที่รองรับ ACP ภายนอก',
'acp.addAgent': 'เพิ่ม Agent',
'acp.empty': 'ยังไม่ได้กำหนดค่า ACP Agent',
'acp.displayName': 'ชื่อที่แสดง',
'acp.displayNamePlaceholder': 'เช่น Agent ออกแบบของฉัน',
'acp.connectionType': 'ประเภทการเชื่อมต่อ',
'acp.local': 'ภายในเครื่อง',
'acp.remote': 'ระยะไกล',
'acp.command': 'คำสั่ง',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'อาร์กิวเมนต์',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'ตัวแปรสภาพแวดล้อม',
'acp.envPlaceholder': 'KEY=VALUE (บรรทัดละหนึ่ง)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'เชื่อมต่อแล้ว',
'acp.notConnected': 'ไม่ได้เชื่อมต่อ',
'acp.connect': 'เชื่อมต่อ',
'acp.disconnect': 'ยกเลิกการเชื่อมต่อ',
'acp.localDesktopOnly': 'Agent ภายในเครื่องใช้ได้เฉพาะในแอปเดสก์ท็อป',
// ── Figma Import ──
'figma.title': 'นำเข้าจาก Figma',
'figma.dropFile': 'วางไฟล์ .fig ที่นี่',

View file

@ -772,6 +772,30 @@ const tr: TranslationKeys = {
'builtin.teamDesignModel': 'Tasarım Modeli',
'builtin.teamSelectModel': 'Yok (tek ajan)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Harici ACP uyumlu Agent bağlayın.',
'acp.addAgent': 'Agent Ekle',
'acp.empty': 'Yapılandırılmış ACP Agent yok.',
'acp.displayName': 'Görünen Ad',
'acp.displayNamePlaceholder': "örn. Tasarım Agent'ım",
'acp.connectionType': 'Bağlantı Türü',
'acp.local': 'Yerel',
'acp.remote': 'Uzak',
'acp.command': 'Komut',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Argümanlar',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Ortam Değişkenleri',
'acp.envPlaceholder': 'KEY=VALUE (satır başına bir)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Bağlı',
'acp.notConnected': 'Bağlı değil',
'acp.connect': 'Bağlan',
'acp.disconnect': 'Bağlantıyı Kes',
'acp.localDesktopOnly': 'Yerel Agent yalnızca masaüstü uygulamasında kullanılabilir.',
// ── Figma Import ──
'figma.title': "Figma'dan İçe Aktar",
'figma.dropFile': 'Bir .fig dosyasını buraya bırakın',

View file

@ -765,6 +765,30 @@ const vi: TranslationKeys = {
'builtin.teamDesignModel': 'Mô hình thiết kế',
'builtin.teamSelectModel': 'Không (agent đơn)',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': 'Kết nối các Agent tương thích ACP bên ngoài.',
'acp.addAgent': 'Thêm Agent',
'acp.empty': 'Chưa có ACP Agent nào được cấu hình.',
'acp.displayName': 'Tên hiển thị',
'acp.displayNamePlaceholder': 'vd. Agent thiết kế của tôi',
'acp.connectionType': 'Loại kết nối',
'acp.local': 'Cục bộ',
'acp.remote': 'Từ xa',
'acp.command': 'Lệnh',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': 'Đối số',
'acp.argsPlaceholder': '--stdio',
'acp.env': 'Biến môi trường',
'acp.envPlaceholder': 'KEY=VALUE (mỗi dòng một cặp)',
'acp.url': 'URL',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': 'Đã kết nối',
'acp.notConnected': 'Chưa kết nối',
'acp.connect': 'Kết nối',
'acp.disconnect': 'Ngắt kết nối',
'acp.localDesktopOnly': 'Agent cục bộ chỉ khả dụng trong ứng dụng desktop.',
// ── Figma Import ──
'figma.title': 'Nhập từ Figma',
'figma.dropFile': 'Kéo thả tệp .fig vào đây',

View file

@ -753,6 +753,30 @@ const zhTW: TranslationKeys = {
'builtin.teamDesignModel': '設計模型',
'builtin.teamSelectModel': '無(單 Agent',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': '連接外部 ACP 相容的 Agent。',
'acp.addAgent': '新增 Agent',
'acp.empty': '尚未設定 ACP Agent。',
'acp.displayName': '顯示名稱',
'acp.displayNamePlaceholder': '例如:我的設計 Agent',
'acp.connectionType': '連線類型',
'acp.local': '本機程序',
'acp.remote': '遠端服務',
'acp.command': '指令',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': '參數',
'acp.argsPlaceholder': '--stdio',
'acp.env': '環境變數',
'acp.envPlaceholder': 'KEY=VALUE每行一條',
'acp.url': '網址',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': '已連線',
'acp.notConnected': '未連線',
'acp.connect': '連線',
'acp.disconnect': '中斷連線',
'acp.localDesktopOnly': '本機 Agent 僅在桌面應用中可用。',
// ── Figma Import ──
'figma.title': '從 Figma 匯入',
'figma.dropFile': '將 .fig 檔案拖放至此處',

View file

@ -746,6 +746,30 @@ const zh: TranslationKeys = {
'builtin.teamDesignModel': '设计模型',
'builtin.teamSelectModel': '无(单 Agent',
// ── ACP Agents ──
'acp.title': 'ACP Agent',
'acp.description': '连接外部 ACP 兼容的 Agent。',
'acp.addAgent': '添加 Agent',
'acp.empty': '尚未配置 ACP Agent。',
'acp.displayName': '显示名称',
'acp.displayNamePlaceholder': '例如:我的设计 Agent',
'acp.connectionType': '连接类型',
'acp.local': '本地进程',
'acp.remote': '远程服务',
'acp.command': '命令',
'acp.commandPlaceholder': '/usr/bin/myagent',
'acp.args': '参数',
'acp.argsPlaceholder': '--stdio',
'acp.env': '环境变量',
'acp.envPlaceholder': 'KEY=VALUE每行一条',
'acp.url': '地址',
'acp.urlPlaceholder': 'ws://localhost:8100',
'acp.connected': '已连接',
'acp.notConnected': '未连接',
'acp.connect': '连接',
'acp.disconnect': '断开',
'acp.localDesktopOnly': '本地 Agent 仅在桌面应用中可用。',
// ── Figma Import ──
'figma.title': '从 Figma 导入',
'figma.dropFile': '将 .fig 文件拖放到此处',

View file

@ -184,6 +184,95 @@ describe('builtin provider presets', () => {
).toBe('https://api.minimaxi.com/anthropic');
});
it('preserves alternative-format baseURL on canonicalize (e.g. Bailian Coding Plan + Anthropic)', () => {
// Repro for the regression where switching a default-OpenAI preset to
// its Anthropic alt URL would silently get reset to the OpenAI URL on
// save, and the request then went to .../v1/messages → 404.
const altFormat = canonicalizeBuiltinProviderConfig({
id: 'bp-bailian-coding-anthropic',
displayName: 'Bailian Coding Plan',
preset: 'bailian-coding',
type: 'anthropic',
apiKey: 'sk-test',
model: 'qwen3-coder-plus',
baseURL: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
enabled: true,
});
expect(altFormat.preset).toBe('bailian-coding');
expect(altFormat.type).toBe('anthropic');
expect(altFormat.baseURL).toBe('https://coding.dashscope.aliyuncs.com/apps/anthropic');
});
it('round-trips the default OpenAI URL on canonicalize for the same preset', () => {
// Sanity: we did not break the default-format path while fixing the alt-format one.
const defaultFormat = canonicalizeBuiltinProviderConfig({
id: 'bp-bailian-coding-openai',
displayName: 'Bailian Coding Plan',
preset: 'bailian-coding',
type: 'openai-compat',
apiKey: 'sk-test',
model: 'qwen3-coder-plus',
baseURL: 'https://coding.dashscope.aliyuncs.com/v1',
enabled: true,
});
expect(defaultFormat.preset).toBe('bailian-coding');
expect(defaultFormat.type).toBe('openai-compat');
expect(defaultFormat.baseURL).toBe('https://coding.dashscope.aliyuncs.com/v1');
});
it('infers a unique preset from its alternative-format URL (so reload restores it correctly)', () => {
// Previously the URL→preset reverse lookup only knew about default-format
// URLs; an alt-format URL would fall through to 'custom' and lose the
// preset selection on the next reload.
expect(
inferBuiltinProviderPreset({
type: 'anthropic',
baseURL: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
} as any),
).toBe('bailian-coding');
});
it('preserves an explicit preset when two presets share the same alt URL', () => {
// zhipu and glm-coding both point at https://open.bigmodel.cn/api/anthropic
// for their Anthropic alt format. The user's dropdown choice must win.
const glmCoding = canonicalizeBuiltinProviderConfig({
id: 'bp-glm-coding-anthropic',
displayName: 'GLM Coding Plan',
preset: 'glm-coding',
type: 'anthropic',
apiKey: 'key',
model: 'glm-4.7',
baseURL: 'https://open.bigmodel.cn/api/anthropic',
enabled: true,
});
expect(glmCoding.preset).toBe('glm-coding');
expect(glmCoding.baseURL).toBe('https://open.bigmodel.cn/api/anthropic');
const zhipu = canonicalizeBuiltinProviderConfig({
id: 'bp-zhipu-anthropic',
displayName: 'Zhipu',
preset: 'zhipu',
type: 'anthropic',
apiKey: 'key',
model: 'glm-5',
baseURL: 'https://open.bigmodel.cn/api/anthropic',
enabled: true,
});
expect(zhipu.preset).toBe('zhipu');
expect(zhipu.baseURL).toBe('https://open.bigmodel.cn/api/anthropic');
});
it('detects global region from an alt-format URL', () => {
expect(
inferBuiltinProviderRegion({
type: 'anthropic',
baseURL: 'https://coding-intl.dashscope.aliyuncs.com/apps/anthropic',
} as any),
).toBe('global');
});
it('prefers a recognized legacy URL over stale built-in preset metadata during migration', () => {
const migrated = canonicalizeBuiltinProviderConfig({
id: 'bp-stale',

View file

@ -219,10 +219,20 @@ export const BUILTIN_PROVIDER_PRESETS: Record<BuiltinProviderPreset, BuiltinPres
const PRESET_URL_LOOKUP = Object.entries(BUILTIN_PROVIDER_PRESETS).reduce(
(acc, [key, cfg]) => {
if (cfg.baseURL) acc[cfg.baseURL] = key as BuiltinProviderPreset;
const k = key as BuiltinProviderPreset;
if (cfg.baseURL) acc[cfg.baseURL] = k;
if (cfg.regions) {
acc[cfg.regions.cn.baseURL] = key as BuiltinProviderPreset;
acc[cfg.regions.global.baseURL] = key as BuiltinProviderPreset;
acc[cfg.regions.cn.baseURL] = k;
acc[cfg.regions.global.baseURL] = k;
}
// Include alternative-format URLs so a saved Anthropic-format config
// for an OpenAI-default preset (or vice versa) still maps back to the
// correct preset on reload. Without this the canonicalize pass falls
// through to inferBuiltinProviderPreset and may collapse to 'custom'.
if (cfg.altBaseURL) acc[cfg.altBaseURL] = k;
if (cfg.altRegions) {
acc[cfg.altRegions.cn] = k;
acc[cfg.altRegions.global] = k;
}
return acc;
},
@ -255,15 +265,27 @@ function lookupPresetByURL(url?: string): BuiltinProviderPreset | undefined {
return PRESET_URL_LOOKUP[normalizedURL] ?? LEGACY_URL_LOOKUP[normalizedURL];
}
/** Whether `url` equals `base`, or `base` followed by a `/v<digits>` segment.
* Catches legacy entries where an extra version suffix was appended manually
* (`/v1`, `/v3`, etc.) on top of a base that already has its own version. */
function urlMatchesIgnoringVersionSuffix(url: string, base: string): boolean {
if (url === base) return true;
if (!url.startsWith(base + '/')) return false;
const tail = url.slice(base.length + 1);
return /^v\d+$/.test(tail);
}
function inferRegionFromURL(preset: BuiltinProviderPreset, normalizedURL: string): 'cn' | 'global' {
const regions = BUILTIN_PROVIDER_PRESETS[preset].regions;
if (!regions) return 'cn';
const cfg = BUILTIN_PROVIDER_PRESETS[preset];
const regions = cfg.regions;
const altRegions = cfg.altRegions;
if (!regions && !altRegions) return 'cn';
const legacyGlobalURLs = LEGACY_GLOBAL_URL_LOOKUP[preset];
return normalizedURL === regions.global.baseURL ||
normalizedURL === `${regions.global.baseURL}/v1` ||
legacyGlobalURLs?.has(normalizedURL)
? 'global'
: 'cn';
const isGlobal =
(regions && urlMatchesIgnoringVersionSuffix(normalizedURL, regions.global.baseURL)) ||
(altRegions && urlMatchesIgnoringVersionSuffix(normalizedURL, altRegions.global)) ||
legacyGlobalURLs?.has(normalizedURL);
return isGlobal ? 'global' : 'cn';
}
export function inferBuiltinProviderPreset(
@ -318,16 +340,49 @@ export function getCanonicalBuiltinBaseURL(
return cfg.baseURL;
}
/** Whether the given preset's URL family covers `normalizedURL`. */
function presetMatchesURL(preset: BuiltinProviderPreset, normalizedURL: string): boolean {
const cfg = BUILTIN_PROVIDER_PRESETS[preset];
if (cfg.baseURL && urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.baseURL)) return true;
if (cfg.regions) {
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.regions.cn.baseURL)) return true;
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.regions.global.baseURL)) return true;
}
if (cfg.altBaseURL && urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.altBaseURL)) return true;
if (cfg.altRegions) {
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.altRegions.cn)) return true;
if (urlMatchesIgnoringVersionSuffix(normalizedURL, cfg.altRegions.global)) return true;
}
return false;
}
export function canonicalizeBuiltinProviderConfig(
config: BuiltinProviderConfig,
): BuiltinProviderConfig {
if (config.preset === 'custom') return config;
const preset = lookupPresetByURL(config.baseURL) ?? inferBuiltinProviderPreset(config);
const normalizedURL = normalizeURL(config.baseURL);
// Respect an explicit preset when its URL family covers the configured
// baseURL — this is the path that disambiguates presets sharing the same
// alt URL (e.g. zhipu vs glm-coding both point at /api/anthropic). When
// the explicit preset is genuinely stale (URL no longer fits the family),
// fall back to URL-based lookup so legacy entries can self-heal.
// Note: config.preset === 'custom' is already handled by the early return above,
// so config.preset here is non-custom (or undefined).
const preset =
config.preset &&
BUILTIN_PROVIDER_PRESETS[config.preset] &&
presetMatchesURL(config.preset, normalizedURL)
? config.preset
: (lookupPresetByURL(config.baseURL) ?? inferBuiltinProviderPreset(config));
if (preset === 'custom') return config;
const region = inferRegionFromURL(preset, normalizeURL(config.baseURL));
const canonicalBaseURL = getCanonicalBuiltinBaseURL(preset, region);
const region = inferRegionFromURL(preset, normalizedURL);
// Pick the canonical URL for the user's chosen API format. Without this
// the alternative-format selection (e.g. Anthropic on a preset whose
// default is OpenAI-compat) would be silently overwritten on save.
const canonicalBaseURL =
getBaseURLForFormat(preset, config.type, region) ?? getCanonicalBuiltinBaseURL(preset, region);
return {
...config,

View file

@ -94,10 +94,17 @@ export class AgentToolExecutor {
return this.handleBatchInsert(args as { parentId: string | null; nodes: unknown[] });
case 'insert_node':
return this.handleInsertNode(
args as { parent: string | null; data: Record<string, unknown>; pageId?: string },
args as {
parent?: string | null;
after?: string;
data: Record<string, unknown>;
pageId?: string;
},
);
case 'update_node':
return this.handleUpdateNode(args as { id: string; data: Record<string, unknown> });
case 'move_node':
return this.handleMoveNode(args as { id: string; parent: string; index?: number });
case 'delete_node':
return this.handleDeleteNode(args as { id: string });
case 'find_empty_space':
@ -490,7 +497,8 @@ export class AgentToolExecutor {
* 5. Auto-zoom to show new design
*/
private async handleInsertNode(args: {
parent: string | null;
parent?: string | null;
after?: string;
data: Record<string, unknown>;
pageId?: string;
}): Promise<ToolResult> {
@ -536,32 +544,58 @@ export class AgentToolExecutor {
};
const totalNodes = countNodes(node);
// Use insertStreamingNode — the SAME function the CLI streaming pipeline uses.
// MUST call resetGenerationRemapping() first to initialize generation state
// (preExistingNodeIds, generationRootFrameId, generationRemappedIds).
// Without this, insertStreamingNode's ID dedup and parent resolution break.
const { insertStreamingNode, resetGenerationRemapping, setGenerationCanvasWidth } =
await import('@/services/ai/design-canvas-ops');
resetGenerationRemapping();
// Set canvas width for role resolution (mobile: 375, desktop: 1200)
const isMobile = (node as any).width && (node as any).width <= 500;
setGenerationCanvasWidth(isMobile ? 375 : 1200);
const insertRecursive = (n: PenNode, parentId: string | null) => {
const children = 'children' in n && Array.isArray(n.children) ? [...n.children] : [];
const nodeForInsert = { ...n } as PenNode;
if (children.length > 0) {
(nodeForInsert as any).children = [];
const { useDocumentStore } = await import('@/stores/document-store');
const { findParentInTree, getActivePageChildren } =
await import('@/stores/document-tree-utils');
const { useCanvasStore } = await import('@/stores/canvas-store');
const docStore = useDocumentStore.getState();
// Resolve "after" → parent + index
let parentId: string | null = args.parent ?? null;
let insertIndex: number | undefined;
if (args.after) {
const doc = docStore.document;
const activePageId = useCanvasStore.getState().activePageId;
const children = getActivePageChildren(doc, activePageId);
const parent = findParentInTree(children, args.after);
if (parent) {
parentId = parent.id;
const siblings =
'children' in parent && Array.isArray(parent.children) ? parent.children : [];
const siblingIdx = siblings.findIndex((n) => n.id === args.after);
if (siblingIdx >= 0) insertIndex = siblingIdx + 1;
}
insertStreamingNode(nodeForInsert, parentId);
// Use nodeForInsert.id (not n.id) — ensureUniqueNodeIds inside
// insertStreamingNode may have renamed it, and replaceEmptyFrame
// maps from the renamed ID to root-frame via generationRemappedIds.
const actualId = nodeForInsert.id;
for (const child of children) {
insertRecursive(child, actualId);
}
// When inserting into an existing parent, use addNode directly — simple and reliable.
// Only fall back to insertStreamingNode for root-level generation (parent=null)
// where we need the full pipeline (replace empty frame, role resolution, etc.).
if (parentId && docStore.getNodeById(parentId)) {
const parentNode = docStore.getNodeById(parentId)!;
// Strip absolute x/y if parent has auto-layout — let the layout engine position
if ('layout' in parentNode && parentNode.layout && parentNode.layout !== 'none') {
if ('x' in node) delete (node as { x?: number }).x;
if ('y' in node) delete (node as { y?: number }).y;
}
};
insertRecursive(node, args.parent);
docStore.addNode(parentId, node, insertIndex);
} else {
// Root-level insert or unknown parent — use streaming pipeline
const { insertStreamingNode, resetGenerationRemapping, setGenerationCanvasWidth } =
await import('@/services/ai/design-canvas-ops');
resetGenerationRemapping();
const isMobile = (node as any).width && (node as any).width <= 500;
setGenerationCanvasWidth(isMobile ? 375 : 1200);
const insertRecursive = (n: PenNode, pid: string | null) => {
const ch = 'children' in n && Array.isArray(n.children) ? [...n.children] : [];
const nodeForInsert = { ...n } as PenNode;
if (ch.length > 0) (nodeForInsert as any).children = [];
insertStreamingNode(nodeForInsert, pid);
const actualId = nodeForInsert.id;
for (const child of ch) insertRecursive(child, actualId);
};
insertRecursive(node, parentId);
}
this.markContentStarted();
// Auto-zoom to show the new design
@ -596,6 +630,23 @@ export class AgentToolExecutor {
return { success: true };
}
private async handleMoveNode(args: {
id: string;
parent: string;
index?: number;
}): Promise<ToolResult> {
const { useDocumentStore } = await import('@/stores/document-store');
const docStore = useDocumentStore.getState();
if (!docStore.getNodeById(args.id)) {
return { success: false, error: `Node not found: ${args.id}` };
}
if (!docStore.getNodeById(args.parent)) {
return { success: false, error: `Parent not found: ${args.parent}` };
}
docStore.moveNode(args.id, args.parent, args.index ?? -1);
return { success: true };
}
private async handleDeleteNode(args: { id: string }): Promise<ToolResult> {
const { useDocumentStore } = await import('@/stores/document-store');
const docStore = useDocumentStore.getState();

View file

@ -47,11 +47,162 @@ const TOOL_AUTH_MAP: Record<string, AuthLevel> = {
remove_page: 'delete',
};
// ---------------------------------------------------------------------------
// Intent detection — determines which tools and prompts to load
// ---------------------------------------------------------------------------
export type AgentIntent = 'design' | 'crud';
// CJK characters aren't `\w`, so `\b` boundaries silently fail for them.
// Keep the English list boundary-anchored (avoids `app` matching `approach`,
// `add` matching `address`, etc.) and run a separate boundary-free pass for
// CJK keywords.
const DESIGN_KEYWORDS_EN =
/\b(design|create|make|build|generate|add|insert|landing|page|screen|app|dashboard|card|hero|navbar|form|layout)\b/i;
const DESIGN_KEYWORDS_CJK =
/(设计|创建|生成|画|做一个|新建|增加|添加|加一个|插入|页面|界面|登录|首页|仪表盘|卡片|表单)/;
/** Detect whether the user's message is a design intent or a CRUD operation. */
export function detectAgentIntent(message: string): AgentIntent {
return DESIGN_KEYWORDS_EN.test(message) || DESIGN_KEYWORDS_CJK.test(message) ? 'design' : 'crud';
}
// ---------------------------------------------------------------------------
// Tool definitions by intent
// ---------------------------------------------------------------------------
/** CRUD-only tools — lightweight set for read/update/delete/move/insert operations. */
export function getCrudToolDefs(): ToolDef[] {
return [
{
name: 'batch_get',
description:
'Search and read nodes from the document. ALWAYS call this first before update_node or delete_node to find the correct node IDs. ' +
'With no arguments, returns top-level children (current page structure). Search by type/name patterns or read specific IDs.',
level: TOOL_AUTH_MAP.batch_get,
parameters: {
type: 'object',
properties: {
ids: { type: 'array', items: { type: 'string' }, description: 'Node IDs to retrieve' },
patterns: {
type: 'array',
items: { type: 'string' },
description: 'Search patterns to match',
},
},
},
},
{
name: 'snapshot_layout',
description:
'Get a compact layout snapshot of the current page showing node positions and sizes',
level: TOOL_AUTH_MAP.snapshot_layout,
parameters: {
type: 'object',
properties: {
pageId: { type: 'string' },
},
},
},
{
name: 'insert_node',
description:
'Insert a new node into the document tree. Always call snapshot_layout or batch_get first. ' +
'Use "after" to insert next to a sibling (auto-finds parent and position), or "parent" for explicit placement.',
level: TOOL_AUTH_MAP.insert_node,
parameters: {
type: 'object',
properties: {
after: {
type: 'string',
description:
'Insert after this sibling node ID (preferred). Automatically uses the same parent and places the new node right after it.',
},
parent: {
type: ['string', 'null'],
description:
'Explicit parent node ID. Use "after" instead when adding next to existing elements.',
},
data: {
type: 'object',
description: 'PenNode data (type, name, width, height, fills, children, etc.)',
},
},
required: ['data'],
},
},
{
name: 'update_node',
description: 'Update properties of an existing node by ID',
level: TOOL_AUTH_MAP.update_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to update' },
data: { type: 'object', description: 'Properties to update' },
},
required: ['id', 'data'],
},
},
{
name: 'move_node',
description:
'Move a node to a different parent container. Use when you need to reparent a node (e.g. move an element into a frame). ' +
"The node will be placed at the end of the parent's children list by default.",
level: TOOL_AUTH_MAP.move_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to move' },
parent: { type: 'string', description: 'New parent node ID' },
index: { type: 'number', description: 'Position index within parent (optional)' },
},
required: ['id', 'parent'],
},
},
{
name: 'delete_node',
description:
'Delete a node (and all its children) from the document. ' +
'Use when the user asks to remove, delete, or clear elements. ' +
'Always call batch_get first to find the correct node ID before deleting.',
level: TOOL_AUTH_MAP.delete_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to delete' },
},
required: ['id'],
},
},
{
name: 'get_selection',
description: 'Get the currently selected nodes on the canvas with their full data',
level: TOOL_AUTH_MAP.get_selection,
parameters: {
type: 'object',
properties: {},
},
},
];
}
/**
* Design tools minimal set forcing the model through `generate_design`.
*
* Weak models (e.g. MiniMax-M2.7) prefer per-node `insert_node` calls when
* given the choice, producing scattered output instead of a coherent design.
* Read tools (`batch_get`, `snapshot_layout`) stay so the model can inspect
* existing context before generating. CRUD tools live in `getCrudToolDefs()`
* and are reached via `detectAgentIntent('crud')` for surgical edits.
*/
export function getDesignToolDefs(): ToolDef[] {
return [
{
name: 'batch_get',
description: 'Get nodes by IDs or search patterns from the document tree',
description:
'Search and read nodes from the document. ALWAYS call this first before update_node or delete_node to find the correct node IDs. ' +
'With no arguments, returns top-level children (current page structure). Search by type/name patterns or read specific IDs.',
level: TOOL_AUTH_MAP.batch_get,
parameters: {
type: 'object',
@ -94,31 +245,6 @@ export function getDesignToolDefs(): ToolDef[] {
required: ['prompt'],
},
},
{
name: 'update_node',
description: 'Update properties of an existing node by ID',
level: TOOL_AUTH_MAP.update_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to update' },
data: { type: 'object', description: 'Properties to update' },
},
required: ['id', 'data'],
},
},
{
name: 'delete_node',
description: 'Delete a node from the document by ID',
level: TOOL_AUTH_MAP.delete_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to delete' },
},
required: ['id'],
},
},
];
}
@ -177,14 +303,22 @@ export function getAllToolDefs(): ToolDef[] {
},
{
name: 'insert_node',
description: 'Insert a new node into the document tree with full support for nested children',
description:
'Insert a new node into the document tree. Always call snapshot_layout or batch_get first. ' +
'Use "after" to insert next to a sibling (auto-finds parent and position), or "parent" for explicit placement.',
level: TOOL_AUTH_MAP.insert_node,
parameters: {
type: 'object',
properties: {
after: {
type: 'string',
description:
'Insert after this sibling node ID (preferred). Automatically uses the same parent and places the new node right after it.',
},
parent: {
type: ['string', 'null'],
description: 'Parent node ID, or null for root-level insertion',
description:
'Explicit parent node ID. Use "after" instead when adding next to existing elements.',
},
data: {
type: 'object',
@ -195,7 +329,7 @@ export function getAllToolDefs(): ToolDef[] {
description: 'Target page ID (optional, defaults to active page)',
},
},
required: ['parent', 'data'],
required: ['data'],
},
},
{

View file

@ -9,6 +9,36 @@ function loadSkill(name: string): string {
return getSkillByName(name)?.content ?? '';
}
/**
* Strip fields that don't influence code generation. Keeps request bodies small
* enough that proxies don't reject them with 403/413, and reduces input tokens.
*/
function stripNoise(input: unknown): unknown {
if (Array.isArray(input)) return input.map(stripNoise);
if (!input || typeof input !== 'object') return input;
const obj = input as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
// Drop fields the generator doesn't need:
// - id (model picks its own names)
// - parentId / pageId (tree structure is implicit in nesting)
// - x/y on auto-layout children (layout engine positions them)
// - effects we typically can't translate (skip noisy nested props)
// - rotation/opacity/visible when default
if (k === 'id' || k === 'parentId' || k === 'pageId' || k === '_meta') continue;
if (k === 'rotation' && v === 0) continue;
if (k === 'opacity' && v === 1) continue;
if (k === 'visible' && v === true) continue;
out[k] = stripNoise(v);
}
return out;
}
/** Compact JSON for AI prompts (no indentation, drops noise fields). */
function compactNodes(nodes: PenNode[]): string {
return JSON.stringify(stripNoise(nodes));
}
/**
* Build system prompt for Step 1: planning.
*/
@ -67,7 +97,7 @@ export function buildChunkPrompt(
`Generate a ${framework} component named "${suggestedComponentName}".`,
'',
'Nodes (JSON):',
JSON.stringify(nodes, null, 2),
compactNodes(nodes),
depSection,
'',
'Output the code followed by ---CONTRACT--- and the JSON contract.',

View file

@ -185,8 +185,12 @@ export function createMobileStatusBar(variant: StatusBarVariant = 'dark'): PenNo
* Determines the best status bar variant based on a background color.
* Returns 'light' (white icons) for dark backgrounds, 'dark' (black icons) for light ones.
*/
export function inferStatusBarVariant(bgColor?: string): StatusBarVariant {
if (!bgColor) return 'dark';
export function inferStatusBarVariant(bgColor?: string | unknown): StatusBarVariant {
// Defensive: callers occasionally pass non-string values (e.g. a fill
// object or a `$variable` ref-shaped object) when the upstream PenNode
// hasn't been variable-resolved yet. Bail to the safe default instead
// of throwing `bgColor.replace is not a function`.
if (typeof bgColor !== 'string' || !bgColor) return 'dark';
const hex = bgColor.replace(/^#/, '').slice(0, 6);
if (hex.length !== 6) return 'dark';
const r = parseInt(hex.slice(0, 2), 16);

View file

@ -5,6 +5,7 @@ import type {
MCPCliIntegration,
MCPTransportMode,
GroupedModel,
AcpAgentConfig,
} from '@/types/agent-settings';
import type { ImageGenConfig, ImageGenProfile } from '@/types/image-service';
import { DEFAULT_IMAGE_GEN_CONFIG } from '@/types/image-service';
@ -56,11 +57,16 @@ interface PersistedState {
activeImageGenProfileId: string | null;
openverseOAuth: { clientId: string; clientSecret: string } | null;
builtinProviders: BuiltinProviderConfig[];
acpAgents: AcpAgentConfig[];
teamEnabled: boolean;
teamDesignModel: string | null;
}
interface AgentSettingsState extends PersistedState {
acpConnectionStatus: Record<
string,
{ isConnected: boolean; agentInfo?: { name: string; title?: string; version?: string } }
>;
dialogOpen: boolean;
isHydrated: boolean;
mcpServerRunning: boolean;
@ -88,6 +94,16 @@ interface AgentSettingsState extends PersistedState {
addBuiltinProvider: (config: Omit<BuiltinProviderConfig, 'id'>) => string;
updateBuiltinProvider: (id: string, updates: Partial<BuiltinProviderConfig>) => void;
removeBuiltinProvider: (id: string) => void;
addAcpAgent: (config: Omit<AcpAgentConfig, 'id'>) => string;
updateAcpAgent: (id: string, updates: Partial<AcpAgentConfig>) => void;
removeAcpAgent: (id: string) => void;
setAcpConnectionStatus: (
id: string,
status: {
isConnected: boolean;
agentInfo?: { name: string; title?: string; version?: string };
},
) => void;
setTeamEnabled: (enabled: boolean) => void;
setTeamDesignModel: (model: string | null) => void;
persist: () => void;
@ -151,8 +167,10 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
activeImageGenProfileId: null,
openverseOAuth: null,
builtinProviders: [],
acpAgents: [],
teamEnabled: false,
teamDesignModel: null,
acpConnectionStatus: {},
dialogOpen: false,
isHydrated: false,
mcpServerRunning: false,
@ -264,6 +282,33 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
builtinProviders: s.builtinProviders.filter((p) => p.id !== id),
})),
addAcpAgent: (config) => {
const id = `acp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
set((s) => ({ acpAgents: [...s.acpAgents, { ...config, id }] }));
get().persist();
return id;
},
updateAcpAgent: (id, updates) => {
set((s) => ({
acpAgents: s.acpAgents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
}));
get().persist();
},
removeAcpAgent: (id) => {
set((s) => ({
acpAgents: s.acpAgents.filter((a) => a.id !== id),
acpConnectionStatus: Object.fromEntries(
Object.entries(s.acpConnectionStatus).filter(([k]) => k !== id),
),
}));
get().persist();
},
setAcpConnectionStatus: (id, status) => {
set((s) => ({
acpConnectionStatus: { ...s.acpConnectionStatus, [id]: status },
}));
},
setTeamEnabled: (teamEnabled) => set({ teamEnabled }),
setTeamDesignModel: (teamDesignModel) => set({ teamDesignModel }),
@ -279,6 +324,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
activeImageGenProfileId,
openverseOAuth,
builtinProviders,
acpAgents,
teamEnabled,
teamDesignModel,
} = get();
@ -294,6 +340,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
activeImageGenProfileId,
openverseOAuth,
builtinProviders,
acpAgents,
teamEnabled,
teamDesignModel,
}),
@ -357,6 +404,10 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
) as BuiltinProviderConfig[],
});
}
const stored = data as Record<string, unknown>;
set({
acpAgents: Array.isArray(stored.acpAgents) ? (stored.acpAgents as AcpAgentConfig[]) : [],
});
if ((data as Record<string, unknown>).teamEnabled !== undefined) {
set({ teamEnabled: (data as Record<string, unknown>).teamEnabled as boolean });
}

View file

@ -34,13 +34,24 @@ export interface GroupedModel {
value: string;
displayName: string;
description: string;
provider: AIProviderType;
provider: AIProviderType | string;
/** When set, this model came from a built-in provider (API key) rather than a CLI tool */
builtinProviderId?: string;
}
export interface ModelGroup {
provider: AIProviderType;
provider: AIProviderType | string;
providerName: string;
models: GroupedModel[];
}
export interface AcpAgentConfig {
id: string;
displayName: string;
connectionType: 'local' | 'remote';
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
enabled: boolean;
}

View file

@ -43,6 +43,8 @@ declare global {
readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>;
getPendingFile: () => Promise<string | null>;
syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void;
/** Resolve the absolute filesystem path of a File from drag-and-drop. */
getPathForFile: (file: File) => string | null;
confirmClose: () => void;
confirmUnsavedChanges: (payload: {
message: string;

View file

@ -5,6 +5,7 @@
"": {
"name": "openpencil",
"dependencies": {
"@agentclientprotocol/sdk": "^0.18.2",
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@github/copilot-sdk": "^0.1.32",
@ -78,7 +79,7 @@
},
"apps/cli": {
"name": "@zseven-w/openpencil",
"version": "0.6.1",
"version": "0.7.1",
"bin": {
"op": "dist/openpencil-cli.cjs",
},
@ -89,13 +90,14 @@
},
"apps/desktop": {
"name": "@zseven-w/desktop",
"version": "0.6.1",
"version": "0.7.1",
},
"apps/web": {
"name": "@zseven-w/web",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/agent-native": "workspace:*",
"@zseven-w/pen-acp": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-engine": "workspace:*",
@ -109,11 +111,23 @@
},
"packages/agent-native/napi": {
"name": "@zseven-w/agent-native",
"version": "0.1.0",
"version": "0.2.0",
},
"packages/pen-acp": {
"name": "@zseven-w/pen-acp",
"version": "0.7.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.18.2",
"ws": "^8.18.0",
},
"devDependencies": {
"@types/ws": "^8.5.0",
"typescript": "^5.7.2",
},
},
"packages/pen-ai-skills": {
"name": "@zseven-w/pen-ai-skills",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-types": "workspace:*",
"gray-matter": "^4.0.3",
@ -122,7 +136,7 @@
},
"packages/pen-core": {
"name": "@zseven-w/pen-core",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-types": "workspace:*",
"nanoid": "^5.1.6",
@ -134,7 +148,7 @@
},
"packages/pen-engine": {
"name": "@zseven-w/pen-engine",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-figma": "workspace:*",
@ -155,7 +169,7 @@
},
"packages/pen-figma": {
"name": "@zseven-w/pen-figma",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-types": "workspace:*",
"fzstd": "^0.1.1",
@ -169,7 +183,7 @@
},
"packages/pen-mcp": {
"name": "@zseven-w/pen-mcp",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@iconify-json/feather": "^1.2.1",
"@iconify-json/lucide": "^1.2.93",
@ -186,7 +200,7 @@
},
"packages/pen-react": {
"name": "@zseven-w/pen-react",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-engine": "workspace:*",
@ -215,7 +229,7 @@
},
"packages/pen-renderer": {
"name": "@zseven-w/pen-renderer",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-types": "workspace:*",
@ -232,7 +246,7 @@
},
"packages/pen-sdk": {
"name": "@zseven-w/pen-sdk",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-engine": "workspace:*",
@ -247,7 +261,7 @@
},
"packages/pen-types": {
"name": "@zseven-w/pen-types",
"version": "0.6.1",
"version": "0.7.1",
"devDependencies": {
"typescript": "^5.7.2",
},
@ -258,6 +272,8 @@
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.18.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-l/o9NKvUc00GPa6RFJ4AccQq2O/PAf83xQ75mThHuL3H571iN4+PEdwnTBez67sS8Nv2aSA373xCZ5CbTXEwzA=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.97", "", { "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-754teaU0nfrn9BC0YWzPjSbJj253GfPUtuUnkrde7LGsaKtFSjEEuQJq5skJvpozqcn+B8frrtWVPkvFdnupTw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.77.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A=="],
@ -892,6 +908,8 @@
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
@ -920,6 +938,8 @@
"@zseven-w/openpencil": ["@zseven-w/openpencil@workspace:apps/cli"],
"@zseven-w/pen-acp": ["@zseven-w/pen-acp@workspace:packages/pen-acp"],
"@zseven-w/pen-ai-skills": ["@zseven-w/pen-ai-skills@workspace:packages/pen-ai-skills"],
"@zseven-w/pen-core": ["@zseven-w/pen-core@workspace:packages/pen-core"],

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.7.0",
"version": "0.7.1",
"private": true,
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
"author": {
@ -26,13 +26,16 @@
"mcp:dev": "bun run packages/pen-mcp/src/server.ts",
"electron:dev": "bun run apps/desktop/dev.ts",
"electron:compile": "esbuild apps/desktop/main.ts apps/desktop/preload.ts --bundle --platform=node --target=node20 --outdir=out/desktop --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-types=./packages/pen-types/src",
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml",
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && bun run agent-native:bundle && npx electron-builder --config apps/desktop/electron-builder.yml",
"electron:build:mac-arm64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --arm64 && if [ -f out/release/latest-mac.yml ]; then mv out/release/latest-mac.yml out/release/latest-mac-arm64.yml; fi",
"electron:build:mac-x64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --x64",
"electron:build:mac-universal": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && bun run cli:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --arm64 --x64",
"electron:build:mac-both": "bun run electron:build:mac-arm64 && bun run electron:build:mac-x64",
"cli:compile": "esbuild apps/cli/src/index.ts --bundle --platform=node --target=node20 --outfile=apps/cli/dist/openpencil-cli.cjs --format=cjs --sourcemap --alias:@zseven-w/pen-types=./packages/pen-types/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-figma=./packages/pen-figma/src --alias:@zseven-w/pen-renderer=./packages/pen-renderer/src --alias:@zseven-w/pen-sdk=./packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=./packages/pen-ai-skills/src --alias:@zseven-w/pen-mcp=./packages/pen-mcp/src --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --define:import.meta.env={} --external:canvas --external:paper",
"cli:bundle-skill": "bun scripts/bundle-skill.ts",
"cli:compile": "bun run cli:bundle-skill && esbuild apps/cli/src/index.ts --bundle --platform=node --target=node20 --outfile=apps/cli/dist/openpencil-cli.cjs --format=cjs --sourcemap --alias:@zseven-w/pen-types=./packages/pen-types/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-figma=./packages/pen-figma/src --alias:@zseven-w/pen-renderer=./packages/pen-renderer/src --alias:@zseven-w/pen-sdk=./packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=./packages/pen-ai-skills/src --alias:@zseven-w/pen-mcp=./packages/pen-mcp/src --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --alias:@zseven-w/pen-acp=./packages/pen-acp/src --define:import.meta.env={} --external:canvas --external:paper",
"cli:dev": "bun run apps/cli/src/index.ts",
"acp:dev": "bun run packages/pen-acp/src/server.ts",
"acp:compile": "esbuild packages/pen-acp/src/server.ts --bundle --platform=node --target=node20 --outfile=out/acp-server.cjs --format=cjs --sourcemap --alias:@zseven-w/pen-types=./packages/pen-types/src --alias:@zseven-w/pen-core=./packages/pen-core/src --alias:@zseven-w/pen-figma=./packages/pen-figma/src --alias:@zseven-w/pen-renderer=./packages/pen-renderer/src --alias:@zseven-w/pen-sdk=./packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=./packages/pen-ai-skills/src --alias:@zseven-w/pen-mcp=./packages/pen-mcp/src --alias:@zseven-w/pen-engine=./packages/pen-engine/src --alias:@zseven-w/pen-react=./packages/pen-react/src --define:import.meta.env={} --external:canvas --external:paper",
"publish:beta": "bash scripts/publish-beta.sh",
"unpublish": "bash scripts/unpublish.sh",
"bump": "sh -c 'V=$0; [ -z \"$V\" ] && echo \"Usage: bun run bump <version>\" && exit 1; for f in package.json apps/*/package.json packages/*/package.json; do [ -f \"$f\" ] && jq --arg v \"$V\" \".version=\\$v\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\" && echo \"$f → $V\"; done'",
@ -42,9 +45,11 @@
"format:check": "oxfmt --check .",
"generate": "bun -e \"import { compileSkills } from './packages/pen-ai-skills/vite-plugin-skills'; compileSkills('./packages/pen-ai-skills')\"",
"agent:build": "cd packages/agent-native && zig build napi -Doptimize=ReleaseFast",
"agent-native:bundle": "node -e \"const fs=require('fs'),p=require('path');const src=p.resolve('packages/agent-native/zig-out/napi/agent_napi.node');const dst=p.resolve('packages/agent-native/napi/agent_napi.node');if(!fs.existsSync(src)){console.error('Native binary missing at '+src+'. Run bun run agent:build first.');process.exit(1)}fs.copyFileSync(src,dst);console.log('[agent-native:bundle] copied to '+dst)\"",
"postinstall": "(bun scripts/patch-srvx-bun.ts && bun run generate && node scripts/ensure-agent-native.cjs) || true"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.18.2",
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@github/copilot-sdk": "^0.1.32",

@ -1 +1 @@
Subproject commit 7a309968515df9bafb5d12eb4716299b402b556f
Subproject commit e07d743a16ccab79956ff9f5bce5b0729ac9e701

View file

@ -0,0 +1,23 @@
{
"name": "@zseven-w/pen-acp",
"version": "0.7.1",
"description": "ACP (Agent Client Protocol) client for OpenPencil — connect to external ACP agents",
"files": [
"src"
],
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.18.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.0",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,144 @@
import { spawn } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import type { AcpAgentConfig, AcpConnectionState } from './types';
/** Establish an ACP connection to a local process or remote endpoint. */
export async function connectAcpAgent(config: AcpAgentConfig): Promise<AcpConnectionState> {
if (config.connectionType === 'local') {
return connectLocal(config);
}
return connectRemote(config);
}
/**
* Create a ClientSideConnection from a bidirectional ndJSON stream.
* The Client.sessionUpdate callback dispatches events to
* state.sessionUpdateEmitter so the SSE handler can consume them.
*/
function createConnection(
stream: ReturnType<typeof ndJsonStream>,
state: AcpConnectionState,
): ClientSideConnection {
return new ClientSideConnection(
(_agent) => ({
sessionUpdate: async (params) => {
state.sessionUpdateEmitter?.dispatchEvent(new CustomEvent('update', { detail: params }));
},
// Auto-approve all tool calls. The user already established trust by
// connecting this ACP agent in settings. Claude Agent ACP requests
// permission before each MCP tool call — if we don't approve, tools
// fail with "Tool use aborted".
// Future: route through AgentToolExecutor's TOOL_AUTH_MAP if per-call
// approval is needed for destructive operations.
requestPermission: async (params) => {
// Prefer the first allow option if present; fall back to generic allow.
const allowOption = params.options?.find(
(o) =>
o.kind === 'allow_once' || o.kind === 'allow_always' || o.optionId.startsWith('allow'),
);
return {
outcome: {
outcome: 'selected' as const,
optionId: allowOption?.optionId ?? params.options?.[0]?.optionId ?? 'allow',
},
};
},
}),
stream,
);
}
async function connectLocal(config: AcpAgentConfig): Promise<AcpConnectionState> {
if (!config.command) throw new Error('Local ACP agent requires a command');
const proc = spawn(config.command, config.args ?? [], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...config.env },
});
// node:stream toWeb returns ReadableStream<any>; ndJsonStream expects
// ReadableStream<Uint8Array>. The runtime data is bytes, so the cast is
// safe — only TypeScript's variance is too strict here.
const input = Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>;
const output = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
const state: AcpConnectionState = {
connection: null!,
agentInfo: { name: 'unknown' },
process: proc,
sessionUpdateEmitter: null,
};
state.connection = createConnection(stream, state);
const initResult = await state.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: 'openpencil', version: '0.7.1' },
});
state.agentInfo = {
name: initResult.agentInfo?.name ?? config.displayName,
title: initResult.agentInfo?.title ?? undefined,
version: initResult.agentInfo?.version ?? undefined,
};
return state;
}
async function connectRemote(config: AcpAgentConfig): Promise<AcpConnectionState> {
if (!config.url) throw new Error('Remote ACP agent requires a URL');
const { WebSocket: WS } = await import('ws');
const ws = new WS(config.url);
await new Promise<void>((resolve, reject) => {
ws.addEventListener('open', () => resolve());
ws.addEventListener('error', (e) => reject(new Error(`WebSocket error: ${e}`)));
});
const readable = new ReadableStream<Uint8Array>({
start(controller) {
ws.addEventListener('message', (e) => {
const data = typeof e.data === 'string' ? e.data : String(e.data);
controller.enqueue(new TextEncoder().encode(data + '\n'));
});
ws.addEventListener('close', () => controller.close());
},
});
const writable = new WritableStream<Uint8Array>({
write(chunk) {
ws.send(new TextDecoder().decode(chunk));
},
});
const stream = ndJsonStream(writable, readable);
const state: AcpConnectionState = {
connection: null!,
agentInfo: { name: 'unknown' },
sessionUpdateEmitter: null,
};
state.connection = createConnection(stream, state);
const initResult = await state.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: 'openpencil', version: '0.7.1' },
});
state.agentInfo = {
name: initResult.agentInfo?.name ?? config.displayName,
title: initResult.agentInfo?.title ?? undefined,
version: initResult.agentInfo?.version ?? undefined,
};
return state;
}
/** Disconnect an ACP connection and kill the process if local. */
export function disconnectAcpAgent(state: AcpConnectionState): void {
if (state.process) {
state.process.kill('SIGTERM');
}
}

View file

@ -0,0 +1,70 @@
import type { SessionNotification } from '@agentclientprotocol/sdk';
/** Convert an ACP session/update notification to an OpenPencil SSE event string. */
export function acpUpdateToSSE(notification: SessionNotification): string | null {
const update = notification.update;
if (!update) return null;
switch (update.sessionUpdate) {
case 'agent_message_chunk': {
const content = update.content;
if (content && 'text' in content && content.type === 'text') {
return formatSSE('text', { type: 'text', content: content.text });
}
return null;
}
case 'tool_call': {
// ACP tool calls are display-only — the agent executes them via MCP.
// level: 'orchestrate' makes AgentToolExecutor skip execution.
return formatSSE('tool_call', {
type: 'tool_call',
id: update.toolCallId,
name: update.title ?? 'unknown',
args: update.rawInput ?? {},
level: 'orchestrate',
});
}
case 'tool_call_update': {
if (update.status === 'completed' || update.status === 'failed') {
// On failure, extract error details from content blocks (ACP places
// error text in content[].content when tool execution fails).
let errorMsg: string | undefined;
if (update.status === 'failed') {
const content = (update as { content?: Array<{ content?: unknown }> }).content;
if (Array.isArray(content)) {
for (const block of content) {
if (block?.content && typeof block.content === 'object' && 'text' in block.content) {
errorMsg = (block.content as { text?: string }).text ?? errorMsg;
}
}
}
// Log to server console for debugging
console.error(
`[acp] tool ${update.toolCallId} failed:`,
errorMsg ?? JSON.stringify(update.rawOutput),
);
}
return formatSSE('tool_result', {
type: 'tool_result',
id: update.toolCallId,
name: '',
result: {
success: update.status === 'completed',
data: update.rawOutput,
error: errorMsg,
},
});
}
return null;
}
default:
return null;
}
}
function formatSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}

View file

@ -0,0 +1,3 @@
export type { AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState } from './types';
export { connectAcpAgent, disconnectAcpAgent } from './client';
export { acpUpdateToSSE } from './event-adapter';

View file

@ -0,0 +1,41 @@
import type { ClientSideConnection } from '@agentclientprotocol/sdk';
/** Persisted config for a user-configured ACP agent. */
export interface AcpAgentConfig {
id: string;
displayName: string;
connectionType: 'local' | 'remote';
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
enabled: boolean;
}
/** Info returned by the ACP agent during initialize handshake. */
export interface AcpAgentInfo {
name: string;
title?: string;
version?: string;
}
/** Result of a connect attempt. */
export interface AcpConnectResult {
connected: boolean;
agentInfo?: AcpAgentInfo;
error?: string;
}
/** Live connection state held by the connection manager. */
export interface AcpConnectionState {
connection: ClientSideConnection;
agentInfo: AcpAgentInfo;
process?: import('node:child_process').ChildProcess;
/**
* Session-scoped event emitter for session/update notifications.
* Set by the prompt handler before calling connection.prompt().
* The Client.sessionUpdate callback pushes events here;
* the SSE stream handler consumes them.
*/
sessionUpdateEmitter: EventTarget | null;
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,181 @@
# @zseven-w/pen-ai-skills
AI prompt skill engine for [OpenPencil](https://github.com/ZSeven-W/openpencil) — phase-driven prompt loading with intent matching, token budgets, and design memory.
## Install
```bash
npm install @zseven-w/pen-ai-skills
# or
bun add @zseven-w/pen-ai-skills
```
## Overview
When an LLM generates designs for OpenPencil, it needs context: PenNode schema, layout rules, semantic roles, icon names, style guides, and more. Loading everything at once wastes tokens. This package solves that with **phase-based skill resolution** — only the relevant prompts are loaded for each stage of the design workflow.
```
User message ──► resolveSkills(phase, message, options)
├─ Phase filter (planning / generation / validation / maintenance)
├─ Intent matching (landing page? dashboard? form? mobile app?)
├─ Flag-based activation (hasDesignMd? hasVariables?)
├─ Priority sorting (higher priority = selected first)
└─ Token budget trimming (cap per phase)
AgentContext { skills[], memory, budget }
```
## Quick Start
```typescript
import { resolveSkills } from '@zseven-w/pen-ai-skills';
// Generation phase — user wants a landing page
const ctx = resolveSkills('generation', 'design a SaaS landing page', {
flags: { hasDesignMd: false, hasVariables: true },
});
// ctx.skills contains the relevant prompts
for (const skill of ctx.skills) {
console.log(`${skill.meta.name} (${skill.tokenCount} tokens)`);
// schema (800 tokens)
// layout (600 tokens)
// landing-page (400 tokens)
// ...
}
// Budget tracking
console.log(`${ctx.budget.used} / ${ctx.budget.max} tokens used`);
```
## Phases
| Phase | Budget | Purpose |
| ------------- | ------ | -------------------------------------------------------- |
| `planning` | 4,000 | Analyze requirements, plan sections, choose style |
| `generation` | 8,000 | Generate PenNode trees with full design knowledge |
| `validation` | 3,000 | Check layout, spacing, accessibility, best practices |
| `maintenance` | 5,000 | Edit existing nodes, delete, reparent, modify properties |
## Skill Categories
### Base Skills
Core design principles and workflow guides. Always loaded for their phase.
### Domain Skills
Activated by intent matching — keywords in the user message trigger specialized knowledge:
| Skill | Triggers |
| -------------- | ----------------------------------------------- |
| Landing page | `landing`, `marketing`, `homepage` |
| Dashboard | `dashboard`, `admin`, `analytics` |
| Form UI | `form`, `login`, `signup`, `input` |
| Mobile app | `mobile`, `app`, `screen`, `ios`, `android` |
| CJK typography | `chinese`, `japanese`, `korean`, CJK characters |
### Knowledge Skills
Reference material loaded by priority until the token budget is exhausted:
- **Role definitions** — semantic roles (button, input, card, navbar) and their auto-defaults
- **Icon catalog** — Lucide/Feather icon naming conventions
- **Design examples** — complete component patterns in DSL
- **Copywriting** — headline length, CTA text, placeholder copy rules
- **Code generation guides** — React, Vue, Svelte, Flutter, SwiftUI, Compose, React Native, HTML
## Design Memory
Track context across multi-turn generation sessions:
### Document Context
```typescript
import { createDesignContext, contextToPromptString } from '@zseven-w/pen-ai-skills';
const ctx = createDesignContext('/path/to/doc.op');
// Accumulates: palette, typography, spacing, aesthetic, page structure
const prompt = contextToPromptString(ctx);
// "Design system: palette #2563EB, #F8FAFC; font Space Grotesk / Inter; ..."
```
### Generation History
```typescript
import { createHistoryEntry, getRecentEntries } from '@zseven-w/pen-ai-skills';
const entry = createHistoryEntry({
documentPath: '/path/to/doc.op',
prompt: 'design a pricing section',
phase: 'generation',
skillsUsed: ['schema', 'layout', 'landing-page'],
nodeCount: 28,
sectionTypes: ['pricing'],
});
// Feed recent history back to prevent repetitive designs
const recent = getRecentEntries(allEntries, 5);
```
## Diagnostics
Detect common design issues in generated output:
```typescript
import { detectAllIssues } from '@zseven-w/pen-ai-skills';
const issues = detectAllIssues(document);
// [{ severity: 'warning', category: 'invisible-container', nodeId: 'frame-7', message: '...' }]
```
| Detector | Catches |
| ----------------------- | -------------------------------------------------------- |
| Invisible containers | Frames with no fill, stroke, or visual children |
| Empty paths | Path nodes with no `d` attribute |
| Text explicit heights | Text nodes with hardcoded pixel height (causes overflow) |
| Sibling inconsistencies | Siblings with mixed width strategies in the same layout |
## Adding a Skill
Create a Markdown file in `skills/` with YAML frontmatter:
```markdown
---
name: my-custom-skill
description: Guidelines for designing checkout flows
phase: [generation, validation]
trigger:
keywords: [checkout, cart, payment, purchase]
priority: 8
budget: 1500
category: domain
---
## Checkout Flow Design Rules
1. Always show order summary alongside the form
2. Use a single-column layout for payment fields
3. ...
```
The Vite plugin auto-compiles skills into a TypeScript registry on save during development.
## Style Guide
Parse and apply external style guides:
```typescript
import { parseStyleGuideFile, buildStyleMapping } from '@zseven-w/pen-ai-skills';
const guide = parseStyleGuideFile(markdownContent);
const mappings = buildStyleMapping(guide);
// [{ property: 'fill', from: '#000', to: '$text-primary' }, ...]
```
## License
[MIT](./LICENSE)

View file

@ -1,6 +1,20 @@
{
"name": "@zseven-w/pen-ai-skills",
"version": "0.7.0",
"version": "0.7.1",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-ai-skills",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-ai-skills"
},
"files": [
"src",
"skills",

View file

@ -0,0 +1,40 @@
# pen-core
Pure document tree operations, layout engine, variables, normalization, boolean ops, and merge utilities.
## Structure
- `src/tree-utils.ts` — Tree CRUD: `findNodeInTree`, `findParentInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes`, `insertNodeInTree`, `isDescendantOf`, `getNodeBounds`, `findClearX`, `scaleChildrenInPlace`, `rotateChildrenInPlace`, `nodeTreeToSummary`; page helpers: `createEmptyDocument`, `getActivePage`, `getActivePageChildren`, `setActivePageChildren`, `getAllChildren`, `migrateToPages`, `ensureDocumentNodeIds`; clone utilities: `deepCloneNode`, `cloneNodeWithNewIds`, `cloneNodesWithNewIds`; constants: `DEFAULT_FRAME_ID`, `DEFAULT_PAGE_ID`
- `src/normalize.ts``normalizePenDocument`: format-only normalization (fill type "color" to "solid", gradient stop position to offset, sizing strings, padding arrays). Preserves `$variable` refs
- `src/boolean-ops.ts``canBooleanOp`, `executeBooleanOp` (union/subtract/intersect via Paper.js headless)
- `src/sync-lock.ts``isFabricSyncLocked`, `setFabricSyncLock`: prevents circular document-store to canvas sync
- `src/arc-path.ts``buildEllipseArcPath`, `isArcEllipse`: SVG arc path generation for partial ellipses
- `src/path-anchors.ts``anchorsToPathData`, `pathDataToAnchors`, `getPathBoundsFromAnchors`, `inferPathAnchorPointType`
- `src/font-utils.ts``cssFontFamily`: CSS font-family string builder
- `src/node-helpers.ts``isBadgeOverlayNode`, `sanitizeName` (PascalCase conversion)
- `src/design-md-parser.ts``parseDesignMd`, `generateDesignMd`, `designMdColorsToVariables`, `extractDesignMdFromDocument`
- `src/constants.ts` — Canvas rendering constants (zoom limits, colors, snap thresholds, pen tool sizes, guide styling)
- `src/id.ts``generateId` (nanoid wrapper)
- `src/layout/engine.ts` — Auto-layout computation: `resolvePadding`, `getNodeWidth`, `getNodeHeight`, `computeLayoutPositions`, `inferLayout`, `fitContentWidth`, `fitContentHeight`, `isNodeVisible`, `setRootChildrenProvider`, `getRootFillWidthFallback`
- `src/layout/text-measure.ts` — Text measurement: `estimateTextWidth`, `estimateTextWidthPrecise`, `estimateTextHeight`, `resolveTextContent`, `parseSizing`, `defaultLineHeight`, `hasCjkText`, `isCjkCodePoint`, `countWrappedLinesFallback`, `setWrappedLineCounter`
- `src/layout/normalize-tree.ts``normalizeTreeLayout`: infers missing layout mode on frames, strips child x/y in layout containers
- `src/layout/unwrap-fake-phone-mockup.ts``unwrapFakePhoneMockups`: repairs AI-generated fake phone mockup frames
- `src/layout/strip-redundant-section-fills.ts``stripRedundantSectionFills`: removes redundant dark fills from section containers
- `src/normalize/normalize-stroke-fill-schema.ts``normalizeStrokeFillSchema`: repairs AI-generated stroke/fill schema violations
- `src/variables/resolve.ts``isVariableRef`, `getDefaultTheme`, `resolveVariableRef`, `resolveColorRef`, `resolveNumericRef`, `resolveNodeForCanvas`
- `src/variables/replace-refs.ts``replaceVariableRefsInTree`: recursively rename/delete `$variable` refs in node trees
- `src/merge/node-diff.ts``diffDocuments`: one-direction diff producing `NodePatch[]` (add/remove/modify/move)
- `src/merge/node-merge.ts``mergeDocuments`: pure 3-way merge of PenDocument trees, returns `MergeResult` with conflicts
- `src/merge/merge-helpers.ts` — Shared helpers for diff/merge (node indexing, field comparison)
## Key patterns
- All tree operations are pure functions returning new references (structural sharing)
- `$variable` refs are preserved in the document; resolution happens at render time via `resolveNodeForCanvas()`
- Layout engine resolves `fill_container`/`fit_content` sizing and computes absolute positions
## Testing
```bash
bun --bun vitest run packages/pen-core/src/__tests__/
```

21
packages/pen-core/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,89 +1,232 @@
# @zseven-w/pen-core
Core document operations for [OpenPencil](https://github.com/nicepkg/openpencil) — tree manipulation, layout engine, design variables, boolean path operations, and more.
Core document operations for [OpenPencil](https://github.com/ZSeven-W/openpencil) — tree manipulation, layout engine, design variables, boolean path operations, 3-way merge, and more.
## Install
```bash
npm install @zseven-w/pen-core
# or
bun add @zseven-w/pen-core
```
## Overview
`pen-core` is the foundation layer of the OpenPencil stack. It provides pure functions for every document operation — tree CRUD, auto-layout computation, variable resolution, SVG path booleans, and document normalization. All operations are immutable (structural sharing) and framework-free.
## Features
### Document Tree Operations
Create, query, and mutate the document tree:
```ts
```typescript
import {
createEmptyDocument,
findNodeInTree,
findParentInTree,
insertNodeInTree,
removeNodeFromTree,
updateNodeInTree,
deepCloneNode,
flattenNodes,
isDescendantOf,
getNodeBounds,
} from '@zseven-w/pen-core';
const doc = createEmptyDocument();
const node = findNodeInTree(doc.children, 'node-id');
const parent = findParentInTree(doc.children, 'node-id');
const flat = flattenNodes(doc.children); // all nodes in flat array
```
### Node Cloning
Deep clone nodes with optional ID regeneration:
```typescript
import { deepCloneNode, cloneNodeWithNewIds, cloneNodesWithNewIds } from '@zseven-w/pen-core';
const clone = deepCloneNode(node); // preserve IDs
const fresh = cloneNodeWithNewIds(node); // new IDs for all descendants
const batch = cloneNodesWithNewIds([a, b, c]); // batch clone
```
### Multi-Page Support
```ts
import { getActivePage, getActivePageChildren, migrateToPages } from '@zseven-w/pen-core';
```typescript
import {
getActivePage,
getActivePageChildren,
setActivePageChildren,
migrateToPages,
ensureDocumentNodeIds,
} from '@zseven-w/pen-core';
// Migrate a single-page document to multi-page format
const multiPageDoc = migrateToPages(doc);
```
### Layout Engine
Automatic layout computation with auto-sizing, padding, and gap support:
Flexbox-like auto-layout computation supporting `fill_container`, `fit_content`, gap, padding, and alignment:
```ts
```typescript
import {
inferLayout,
computeLayoutPositions,
inferLayout,
fitContentWidth,
fitContentHeight,
resolvePadding,
getNodeWidth,
getNodeHeight,
isNodeVisible,
} from '@zseven-w/pen-core';
// Compute absolute positions for all children in a layout container
const positions = computeLayoutPositions(frame, frame.children);
// Infer layout direction from child arrangement
const layout = inferLayout(children); // 'horizontal' | 'vertical' | 'none'
```
### Text Measurement
Estimate text dimensions for layout without a browser DOM:
```typescript
import {
estimateTextWidth,
estimateTextWidthPrecise,
estimateTextHeight,
resolveTextContent,
hasCjkText,
defaultLineHeight,
} from '@zseven-w/pen-core';
const width = estimateTextWidth('Hello World', 16, 400); // font size, weight
const height = estimateTextHeight(textNode, containerWidth);
const isCjk = hasCjkText('こんにちは'); // true
```
### Design Variables
Resolve `$variable` references against theme axes:
Resolve `$variable` references against document variables and theme axes:
```ts
```typescript
import {
isVariableRef,
resolveVariableRef,
resolveNodeForCanvas,
replaceVariableRefsInTree,
getDefaultTheme,
} from '@zseven-w/pen-core';
isVariableRef('$primary'); // true
// Resolve all $refs in a node tree for rendering
const resolved = resolveNodeForCanvas(node, doc.variables, doc.themes);
// Rename $old-name → $new-name across entire tree
replaceVariableRefsInTree(children, 'old-name', 'new-name');
```
### Boolean Path Operations
Union, subtract, intersect, and exclude paths via Paper.js:
```ts
import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core';
```
```typescript
import { executeBooleanOp, canBooleanOp, BooleanOpType } from '@zseven-w/pen-core';
### Text Measurement
Estimate text dimensions for layout without a browser:
```ts
import { estimateTextWidth, estimateTextHeight } from '@zseven-w/pen-core';
if (canBooleanOp(selectedNodes)) {
const result = executeBooleanOp(selectedNodes, BooleanOpType.Union);
}
```
### Document Normalization
Sanitize and fix documents imported from external sources:
Sanitize and fix documents from external sources (Figma imports, AI generation):
```ts
```typescript
import { normalizePenDocument } from '@zseven-w/pen-core';
const cleaned = normalizePenDocument(rawDoc);
// Fixes: fill type "color" → "solid", gradient stop position → offset,
// sizing strings, padding arrays. Preserves $variable refs.
```
### Layout Normalization
Repair AI-generated layout issues:
```typescript
import {
normalizeTreeLayout,
unwrapFakePhoneMockups,
stripRedundantSectionFills,
normalizeStrokeFillSchema,
} from '@zseven-w/pen-core';
// Infer missing layout modes, strip child x/y in layout containers
normalizeTreeLayout(rootNode);
```
### 3-Way Document Merge
Diff and merge document trees for collaborative editing and git integration:
```typescript
import { diffDocuments, mergeDocuments } from '@zseven-w/pen-core';
// One-direction diff: base → current
const patches = diffDocuments(base, current);
// patches: NodePatch[] — add, remove, modify, move
// 3-way merge: base + ours + theirs
const result = mergeDocuments(base, ours, theirs);
// result: { document, conflicts }
```
### Design.md Parser
Parse and generate design specification documents:
```typescript
import {
parseDesignMd,
generateDesignMd,
designMdColorsToVariables,
extractDesignMdFromDocument,
} from '@zseven-w/pen-core';
```
### Path Anchors
Convert between anchor point representation and SVG path data:
```typescript
import {
anchorsToPathData,
pathDataToAnchors,
getPathBoundsFromAnchors,
inferPathAnchorPointType,
} from '@zseven-w/pen-core';
```
## API Reference
| Category | Key Functions |
| ------------- | ---------------------------------------------------------------------------------------------- |
| **Tree CRUD** | `findNodeInTree`, `insertNodeInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes` |
| **Cloning** | `deepCloneNode`, `cloneNodeWithNewIds`, `cloneNodesWithNewIds` |
| **Pages** | `getActivePage`, `migrateToPages`, `ensureDocumentNodeIds` |
| **Layout** | `computeLayoutPositions`, `inferLayout`, `fitContentWidth`, `fitContentHeight` |
| **Text** | `estimateTextWidth`, `estimateTextHeight`, `hasCjkText` |
| **Variables** | `resolveVariableRef`, `resolveNodeForCanvas`, `replaceVariableRefsInTree` |
| **Boolean** | `executeBooleanOp`, `canBooleanOp` |
| **Normalize** | `normalizePenDocument`, `normalizeTreeLayout` |
| **Merge** | `diffDocuments`, `mergeDocuments` |
| **IDs** | `generateId` |
## License
MIT
[MIT](./LICENSE)

View file

@ -1,7 +1,21 @@
{
"name": "@zseven-w/pen-core",
"version": "0.7.0",
"version": "0.7.1",
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-core",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-core"
},
"files": [
"src"
],

View file

@ -0,0 +1,45 @@
# pen-engine
Headless design engine -- framework-free document, selection, history, viewport, and spatial indexing.
## Structure
### Core (`src/core/`)
- `design-engine.ts``DesignEngine` class: composes all managers, exposes high-level API (`loadDocument`, `addNode`, `updateNode`, `deleteNode`, `moveNode`, `select`, `undo`, `redo`, `setTool`, `generateCode`, `batch`, `on`/`off` events). Zero DOM/React/Zustand dependencies
- `document-manager.ts``DocumentManager`: immutable PenDocument tree mutations with history integration. Methods: `getDocument`, `loadDocument`, `addNode`, `updateNode`, `deleteNode`, `moveNode`, `findNode`, `findParent`, `duplicateNode`
- `history-manager.ts``HistoryManager`: framework-agnostic undo/redo stack with batch support and debouncing (300ms). Methods: `push`, `undo`, `redo`, `clear`, `beginBatch`, `endBatch`
- `selection-manager.ts``SelectionManager`: immutable selection state. Methods: `select`, `clearSelection`, `getSelection`, `getActiveId`, `setHoveredId`
- `page-manager.ts``PageManager`: multi-page lifecycle. Methods: `getActivePage`, `setActivePage`, `addPage`, `removePage`, `renamePage`, `duplicatePage`, `reorderPage`
- `variable-manager.ts``VariableManager`: design variable CRUD with theme support. Methods: `setVariable`, `removeVariable`, `renameVariable`, `setThemes`
- `viewport-controller.ts``ViewportController`: zoom/pan math, coordinate transforms. Methods: `setViewport`, `screenToScene`, `sceneToScreen`, `zoomTo`, `zoomToFit`
- `event-emitter.ts``TypedEventEmitter<Events>`: generic typed pub/sub with `on`/`off`/`emit`/`dispose`
- `node-creator.ts``createNodeForTool(tool, x, y, w, h)`: factory for default PenNodes per tool type; `isDrawingTool(tool)` check
- `svg-parser.ts``parseSvgToNodes(svgString)`: isomorphic SVG to PenNode[] converter (DOMParser in browser, regex fallback in Node.js)
- `spatial-index.ts``EngineSpatialIndex`: wraps pen-renderer's `SpatialIndex` for engine-level hit testing. Methods: `rebuild`, `hitTest`, `searchRect`, `hitTestNode`
- `constants.ts` — Engine constants: `DEFAULT_MAX_HISTORY`, `HISTORY_DEBOUNCE_MS`, `MIN_DRAW_SIZE`, `DRAG_THRESHOLD`, `HANDLE_HIT_RADIUS`, `HANDLE_CURSORS`
### Browser adapter (`src/browser/`)
- `canvas-bindings.ts``attachCanvas(engine, canvasEl, options)`: loads CanvasKit WASM, creates `CanvasBinding` for GPU rendering with auto-rerender on engine events
- `canvas-renderer.ts``CanvasRenderer`: internal class managing CanvasKit surface, render nodes, and redraw loop
- `text-edit-overlay.ts``TextEditOverlayOptions`: DOM textarea overlay for inline text editing
- `interaction/` — Mouse/keyboard event handlers:
- `interaction-controller.ts``attachInteraction(engine, canvasEl)`: binds all pointer/keyboard events
- `select-handler.ts` — Click/drag selection, multi-select, entered-frame navigation
- `draw-handler.ts` — Shape drawing (rectangle, ellipse, frame, line, polygon)
- `resize-handler.ts` — Selection handle resize with aspect ratio support
- `pen-tool-handler.ts` — Bezier pen tool for path creation
- `arc-handler.ts` — Arc/sweep angle editing for ellipses
## Key exports (from `src/index.ts`)
`DesignEngine`, `TypedEventEmitter`, `HistoryManager`, `DocumentManager`, `SelectionManager`, `PageManager`, `VariableManager`, `ViewportController`, `EngineSpatialIndex`, `createNodeForTool`, `isDrawingTool`, `parseSvgToNodes`
Browser adapter (from `src/browser.ts`): `attachCanvas`, `attachInteraction`, `CanvasBinding`, `AttachCanvasOptions`
## Testing
```bash
bun --bun vitest run packages/pen-engine/src/__tests__/
```

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,206 @@
# @zseven-w/pen-engine
Headless design engine for [OpenPencil](https://github.com/ZSeven-W/openpencil) — framework-free document management, selection, history, viewport, and spatial queries. Build your own design tool UI on top of this engine.
## Install
```bash
npm install @zseven-w/pen-engine
# or
bun add @zseven-w/pen-engine
```
## Overview
`pen-engine` is the core runtime that powers OpenPencil's editor. It manages the entire document lifecycle without any framework dependency — React, Vue, Svelte, or vanilla JS all work. The optional `browser.ts` entry adds GPU-accelerated canvas rendering via CanvasKit/Skia.
```
DesignEngine
|- DocumentManager Node CRUD, tree operations
|- SelectionManager Multi-select, hover tracking
|- HistoryManager Undo/redo with debounce + batch
|- PageManager Multi-page support
|- VariableManager Design variables ($refs)
|- ViewportController Zoom, pan, coordinate transforms
|- SpatialIndex R-tree for hit testing & spatial queries
|- EventEmitter Typed event system
```
## Quick Start
```typescript
import { DesignEngine } from '@zseven-w/pen-engine';
const engine = new DesignEngine();
// Load or create a document
engine.loadDocument(myDocument);
// Add a node
engine.addNode(null, {
id: 'frame-1',
type: 'frame',
name: 'Header',
width: 1200,
height: 80,
layout: 'horizontal',
});
// Select, undo, inspect
engine.select(['frame-1']);
engine.undo();
console.log(engine.getDocument());
```
## Features
### Document Operations
Create, query, and mutate the node tree:
```typescript
engine.addNode(parentId, node, index?);
engine.updateNode(id, { fill: [{ type: 'solid', color: '#FF0000' }] });
engine.removeNode(id);
engine.moveNode(id, newParentId, index);
engine.duplicateNode(id);
engine.groupNodes(['node-1', 'node-2']);
engine.ungroupNode(groupId);
engine.getNodeById(id);
```
### Selection & Hover
```typescript
engine.select(['node-1', 'node-2']);
engine.clearSelection();
engine.getSelection(); // string[]
engine.setHoveredId('node-3');
engine.getHoveredId(); // string | null
```
### History (Undo / Redo)
Structural history with debouncing and batch support:
```typescript
engine.undo();
engine.redo();
engine.canUndo; // boolean
engine.canRedo; // boolean
// Batch multiple mutations into a single history entry
engine.batch(() => {
engine.updateNode('a', { x: 100 });
engine.updateNode('b', { x: 200 });
});
```
### Viewport
Pan, zoom, and coordinate conversion:
```typescript
engine.setViewport(zoom, panX, panY);
engine.zoomToRect(x, y, w, h, containerW, containerH);
engine.getContentBounds(); // { x, y, w, h } | null
engine.screenToScene(screenX, screenY); // { x, y }
engine.sceneToScreen(sceneX, sceneY); // { x, y }
```
### Hit Testing (Spatial Index)
R-tree backed queries for click and marquee selection:
```typescript
engine.hitTest(x, y); // PenNode | null
engine.searchRect(x, y, w, h); // PenNode[]
```
### Multi-Page
```typescript
engine.addPage(); // returns pageId
engine.removePage(pageId);
engine.setActivePage(pageId);
engine.getActivePage();
```
### Design Variables
```typescript
engine.setVariable('primary', { type: 'color', value: '#2563EB' });
engine.removeVariable('primary');
engine.renameVariable('primary', 'brand');
engine.resolveVariable('$primary'); // '#2563EB'
```
### SVG Import
Isomorphic SVG parser (DOM in browser, regex fallback in Node.js):
```typescript
import { parseSvgToNodes } from '@zseven-w/pen-engine';
const nodes = parseSvgToNodes(svgString, 400);
engine.addNode(null, nodes[0]);
```
### Events
Typed event system for reactive UI binding:
```typescript
const unsub = engine.on('document:change', (doc) => {
/* re-render */
});
engine.on('selection:change', (ids) => {
/* update UI */
});
engine.on('viewport:change', (viewport) => {
/* update zoom indicator */
});
unsub(); // unsubscribe
```
### Browser Canvas (Optional)
GPU-accelerated rendering via CanvasKit/Skia — import from `@zseven-w/pen-engine/browser`:
```typescript
import { attachCanvas, attachInteraction } from '@zseven-w/pen-engine/browser';
const binding = await attachCanvas(engine, canvasElement);
const detach = attachInteraction(engine, canvasElement);
// Later
binding.dispose();
detach();
```
## API Reference
| Method | Description |
| ------------------------------- | ---------------------- |
| `loadDocument(doc)` | Load a PenDocument |
| `getDocument()` | Get current document |
| `createDocument()` | Create empty document |
| `addNode(parent, node, index?)` | Insert node |
| `updateNode(id, updates)` | Partial update |
| `removeNode(id)` | Delete node + children |
| `moveNode(id, parent, index)` | Reparent node |
| `duplicateNode(id)` | Deep clone |
| `groupNodes(ids)` | Group into frame |
| `ungroupNode(id)` | Dissolve group |
| `select(ids)` | Set selection |
| `undo()` / `redo()` | History navigation |
| `batch(fn)` | Batch mutations |
| `setViewport(z, x, y)` | Set viewport |
| `hitTest(x, y)` | Point query |
| `searchRect(x, y, w, h)` | Area query |
| `importSVG(svg, parent?)` | Parse and insert SVG |
| `dispose()` | Clean up resources |
## License
[MIT](./LICENSE)

View file

@ -1,7 +1,21 @@
{
"name": "@zseven-w/pen-engine",
"version": "0.7.0",
"version": "0.7.1",
"description": "Headless design engine for OpenPencil — zero framework dependencies",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-engine",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-engine"
},
"files": [
"src"
],

View file

@ -0,0 +1,37 @@
# pen-figma
Figma `.fig` binary file parser and converter to PenDocument format.
## Structure
- `src/fig-parser.ts``parseFigFile(buffer)`: binary `.fig` file parser using kiwi-schema, supports zstd/zip compression, embedded images. Returns `FigmaDecodedFile`
- `src/figma-node-mapper.ts``figmaToPenDocument`, `figmaAllPagesToPenDocument`, `getFigmaPages`, `figmaNodeChangesToPenNodes`: top-level conversion from decoded Figma data to PenDocument/PenNode arrays. Resolves style references (fill, stroke, text, effect)
- `src/figma-tree-builder.ts``buildTree`, `buildTreeForClipboard`, `collectComponents`, `collectSymbolTree`, `guidToString`, `isUserPage`: builds parent-child tree from flat Figma node list
- `src/figma-clipboard.ts``isFigmaClipboardHtml`, `extractFigmaClipboardData`, `figmaClipboardToNodes`: handles paste from Figma clipboard (base64-encoded `.fig` data in HTML)
- `src/figma-types.ts` — Internal Figma types: `FigmaDecodedFile`, `FigmaNodeChange`, `FigmaGUID`, `FigmaColor`, `FigmaMatrix`, `FigmaPaintType`, `FigmaImportLayoutMode`
- `src/figma-image-resolver.ts``resolveImageBlobs`: resolves image blob references from decoded `.fig` data to data URLs
- `src/figma-color-utils.ts` — Color space conversion utilities (Figma 0-1 floats to hex)
- `src/converters/` — Node type converters (dispatcher + per-type modules):
- `index.ts``convertNode` dispatcher, `convertChildren` recursive walker
- `common.ts` — Shared helpers: `commonProps`, `extractPosition`, `extractRotation`, `mapCornerRadius`, `resolveWidth/Height`, `scaleTreeChildren`, `collectImageBlobs`, `setIconLookup`, `lookupIconByName`
- `frame-converter.ts``convertFrame`, `convertGroup`, `convertComponent`, `convertInstance`
- `shape-converter.ts``convertRectangle`, `convertEllipse`, `convertLine`
- `text-converter.ts``convertText`
- `path-converter.ts``convertVector` (vector, star, polygon, boolean operation)
- `image-converter.ts` — Image node conversion
- `src/figma-fill-mapper.ts` — Maps Figma paint arrays to `PenFill[]`
- `src/figma-stroke-mapper.ts` — Maps Figma stroke properties to `PenStroke`
- `src/figma-effect-mapper.ts` — Maps Figma effects to `PenEffect[]`
- `src/figma-layout-mapper.ts` — Maps Figma auto-layout to PenNode layout props (direction, gap, padding, alignment)
- `src/figma-text-mapper.ts` — Converts Figma text styles and segments
- `src/figma-vector-decoder.ts` — Decodes Figma vector geometry to SVG path data
## Key exports
`parseFigFile`, `figmaToPenDocument`, `figmaAllPagesToPenDocument`, `getFigmaPages`, `figmaNodeChangesToPenNodes`, `isFigmaClipboardHtml`, `extractFigmaClipboardData`, `figmaClipboardToNodes`, `resolveImageBlobs`, `setIconLookup`
## Testing
```bash
bun --bun vitest run packages/pen-figma/src/converters/__tests__/
```

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,57 +1,159 @@
# @zseven-w/pen-figma
Figma `.fig` file parser and converter for [OpenPencil](https://github.com/nicepkg/openpencil). Import Figma designs directly into the OpenPencil document model.
Figma `.fig` file parser and converter for [OpenPencil](https://github.com/ZSeven-W/openpencil). Import Figma designs directly into the OpenPencil document model — binary file parsing, multi-page support, clipboard paste, and full style conversion.
## Install
```bash
npm install @zseven-w/pen-figma
# or
bun add @zseven-w/pen-figma
```
## Features
## Overview
- Parse binary `.fig` files (Kiwi schema + zstd/zip compression)
- Convert Figma node trees to `PenDocument`
- Multi-page support — import all pages or a single page
- Clipboard paste — detect and convert Figma clipboard HTML
- Image blob resolution
This package handles the complete pipeline from Figma's proprietary binary format to OpenPencil's `PenDocument`:
```
.fig binary → Kiwi schema decode → FigmaNodeChange[] → tree building → PenNode[] → PenDocument
```
It supports:
- Binary `.fig` files (Kiwi schema + zstd/zip compression)
- Figma clipboard HTML (copy from Figma → paste in OpenPencil)
- All node types: frames, groups, components, instances, shapes, text, vectors, images
- Full style conversion: fills, strokes, effects, gradients, auto-layout, typography
## Usage
### Parse a `.fig` file
```ts
```typescript
import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma';
const buffer = await fs.readFile('design.fig');
const figFile = parseFigFile(buffer);
const document = figmaAllPagesToPenDocument(figFile);
console.log(`Imported ${document.pages?.length} pages`);
```
### Single page import
```ts
```typescript
import { parseFigFile, getFigmaPages, figmaToPenDocument } from '@zseven-w/pen-figma';
const figFile = parseFigFile(buffer);
const pages = getFigmaPages(figFile);
// Import just the first page
const document = figmaToPenDocument(figFile, pages[0]);
```
### Clipboard paste
```ts
Detect and convert Figma clipboard data (when users copy from Figma and paste into OpenPencil):
```typescript
import {
isFigmaClipboardHtml,
extractFigmaClipboardData,
figmaClipboardToNodes,
} from '@zseven-w/pen-figma';
if (isFigmaClipboardHtml(html)) {
const data = extractFigmaClipboardData(html);
const nodes = figmaClipboardToNodes(data);
}
document.addEventListener('paste', (e) => {
const html = e.clipboardData?.getData('text/html');
if (html && isFigmaClipboardHtml(html)) {
const data = extractFigmaClipboardData(html);
const nodes = figmaClipboardToNodes(data);
// Insert nodes into document...
}
});
```
### Convert individual nodes
For lower-level access (e.g., incremental sync):
```typescript
import { figmaNodeChangesToPenNodes } from '@zseven-w/pen-figma';
const penNodes = figmaNodeChangesToPenNodes(figmaNodeChanges, options);
```
### Icon resolution
Register an icon lookup function for converting Figma component instances to icon nodes:
```typescript
import { setIconLookup } from '@zseven-w/pen-figma';
setIconLookup((name) => {
// Return SVG path data for the icon name, or null
return iconRegistry[name]?.path ?? null;
});
```
### Image resolution
Resolve embedded image blob references to data URLs:
```typescript
import { resolveImageBlobs } from '@zseven-w/pen-figma';
const images = resolveImageBlobs(figFile);
// Map<blobHash, dataURL>
```
## Conversion Coverage
### Node Types
| Figma Type | PenNode Type | Notes |
| ---------- | ------------ | ------------------------------------------- |
| FRAME | `frame` | With auto-layout, constraints, clip content |
| GROUP | `group` | Preserves child transforms |
| COMPONENT | `frame` | Marked as reusable |
| INSTANCE | `frame` | Resolved with overrides |
| RECTANGLE | `rectangle` | Corner radius (uniform + per-corner) |
| ELLIPSE | `ellipse` | Arc support |
| LINE | `line` | Stroke properties |
| TEXT | `text` | Rich text with styled segments |
| VECTOR | `path` | Complex paths, boolean ops, stars, polygons |
| IMAGE | `image` | Embedded or referenced |
### Styles
| Figma Style | Conversion |
| ---------------- | --------------------------------------------------------------------- |
| Solid fills | `PenFill` with solid type |
| Linear gradients | `PenFill` with gradient stops + angle |
| Radial gradients | `PenFill` with center + radius |
| Image fills | `PenFill` with image source |
| Strokes | `PenStroke` with cap, join, dash |
| Drop shadows | `PenEffect` shadow |
| Inner shadows | `PenEffect` inner shadow |
| Blur | `PenEffect` blur |
| Auto-layout | `layout`, `gap`, `padding`, `justifyContent`, `alignItems` |
| Text styles | `fontSize`, `fontWeight`, `fontFamily`, `lineHeight`, `letterSpacing` |
| Rich text | `StyledTextSegment[]` with per-run styles |
## API Reference
| Function | Description |
| ------------------------------------- | --------------------------------------------- |
| `parseFigFile(buffer)` | Parse binary `.fig` file → `FigmaDecodedFile` |
| `figmaAllPagesToPenDocument(file)` | Convert all pages → `PenDocument` |
| `figmaToPenDocument(file, page)` | Convert single page → `PenDocument` |
| `getFigmaPages(file)` | List available pages |
| `figmaNodeChangesToPenNodes(changes)` | Convert raw node changes → `PenNode[]` |
| `isFigmaClipboardHtml(html)` | Detect Figma clipboard data |
| `extractFigmaClipboardData(html)` | Extract base64 `.fig` from clipboard HTML |
| `figmaClipboardToNodes(data)` | Convert clipboard data → `PenNode[]` |
| `resolveImageBlobs(file)` | Resolve image references → data URLs |
| `setIconLookup(fn)` | Register icon name → SVG path resolver |
## License
MIT
[MIT](./LICENSE)

View file

@ -1,7 +1,21 @@
{
"name": "@zseven-w/pen-figma",
"version": "0.7.0",
"version": "0.7.1",
"description": "Figma .fig file parser and converter for OpenPencil",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-figma",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-figma"
},
"files": [
"src"
],

View file

@ -0,0 +1,78 @@
# pen-mcp
MCP (Model Context Protocol) server for external LLM integration with OpenPencil.
## Structure
### Server
- `src/server.ts` — Standalone MCP server entry point (stdio + HTTP transports). Registers all tool definitions and dispatches to route modules
- `src/document-manager.ts` — File I/O and live canvas sync: `openDocument`, `saveDocument`, `resolveDocPath`, `getCachedDocument`, `setSyncUrl`, `getSyncUrl`, `getLiveSyncState`, `fetchLiveSelection`. Supports both `.op` files and `live://canvas` (Electron/web dev server sync)
- `src/hooks.ts``McpHooks` interface + `configureMcpHooks`/`getMcpHooks`: injectable hooks for role resolution, icon lookup, node sanitization. Web app injects implementations at startup
- `src/constants.ts``MCP_DEFAULT_PORT` (3100), `PORT_FILE_DIR_NAME`, `PORT_FILE_NAME`, `ICONIFY_API_URL`
### Tools (`src/tools/`)
Document tools:
- `open-document.ts``handleOpenDocument`: opens/creates `.op` file or connects to live canvas
- `batch-get.ts``handleBatchGet`: reads document tree with depth control
- `get-selection.ts``handleGetSelection`: returns current selection from live canvas
- `snapshot-layout.ts``handleSnapshotLayout`: captures layout snapshot for comparison
- `find-empty-space.ts``handleFindEmptySpace`: finds unoccupied canvas regions
- `pages.ts` — Page CRUD: `handleAddPage`, `handleRemovePage`, `handleRenamePage`, `handleReorderPage`, `handleDuplicatePage`
Node tools:
- `node-crud.ts``handleInsertNode`, `handleUpdateNode`, `handleDeleteNode`, `handleMoveNode`, `handleCopyNode`, `handleReplaceNode`, `postProcessNode`
- `read-nodes.ts``handleReadNodes`: read specific nodes by ID with depth control
- `import-svg.ts``handleImportSvg`: parse and insert SVG content
Design tools:
- `batch-design.ts``handleBatchDesign`: single-shot batch design DSL
- `design-skeleton.ts``handleDesignSkeleton`: phase 1 of layered design (structure)
- `design-content.ts``handleDesignContent`: phase 2 of layered design (content filling)
- `design-refine.ts``handleDesignRefine`: phase 3 of layered design (polish)
- `design-prompt.ts``buildDesignPrompt`, `listPromptSections`: segmented design knowledge prompt
- `design-md.ts``handleGetDesignMd`, `handleSetDesignMd`, `handleExportDesignMd`
- `layered-design-defs.ts` — Tool definitions for the layered design workflow
Variable/theme tools:
- `variables.ts``handleGetVariables`, `handleSetVariables`, `handleSetThemes`
- `theme-presets.ts``handleSaveThemePreset`, `handleLoadThemePreset`, `handleListThemePresets`
Codegen tools:
- `codegen-plan.ts``handleCodegenPlan`: AI-driven component chunking
- `codegen-submit.ts``handleCodegenSubmit`: per-chunk code generation
- `codegen-assemble.ts``handleCodegenAssemble`: final code assembly
- `codegen-clean.ts``handleCodegenClean`: cleanup stale codegen state
Debug tools:
- `debug-logs-tail.ts` — Tail debug logs
- `debug-screenshot.ts` — Capture canvas screenshot
- `debug-validation-report.ts` — Document validation report
### Routes (`src/routes/`)
Route modules group tool definitions and dispatch handlers by domain: `document-routes.ts`, `node-routes.ts`, `design-routes.ts`, `variable-routes.ts`, `codegen-routes.ts`, `style-guide-routes.ts`, `style-operations-routes.ts`, `debug-routes.ts`
### Utils (`src/utils/`)
- `sanitize.ts``sanitizeObject`: deep-cleans objects for safe serialization
- `id.ts``generateId`: nanoid wrapper
- `node-operations.ts``readNodeWithDepth`: depth-limited node tree reading
- `log-utils.ts``SENSITIVE_LOG_PATTERN`, `readDebugTail`, `readLogTail`: log file reading with sensitive content redaction
- `design-md-parser.ts` — Design.md parsing (re-exported from pen-core)
- `design-md-style-policy.ts``buildDesignMdStylePolicy`: generates style policy from design spec
- `validate-contract.ts``validateContract`: validates codegen chunk contracts
- `svg-node-parser.ts` — SVG to PenNode parser (Node.js environment)
## Testing
```bash
bun --bun vitest run packages/pen-mcp/src/__tests__/
```

21
packages/pen-mcp/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

160
packages/pen-mcp/README.md Normal file
View file

@ -0,0 +1,160 @@
# @zseven-w/pen-mcp
[MCP](https://modelcontextprotocol.io/) server for [OpenPencil](https://github.com/ZSeven-W/openpencil) — enables Claude, GPT, Gemini, and other LLMs to read, create, and modify designs through a standard tool protocol.
## Install
```bash
npm install @zseven-w/pen-mcp
# or
bun add @zseven-w/pen-mcp
```
## Overview
`pen-mcp` exposes OpenPencil's full editing API as MCP tools. External AI agents can open documents, inspect the canvas, insert/update/delete nodes, and generate complete designs — all through structured tool calls.
Three workflows are supported:
| Workflow | Tools | Best for |
| --------------- | ---------------------------------------------------------- | ------------------------------------ |
| **Single-shot** | `insert_node`, `batch_design` | Quick edits, single components |
| **Layered** | `design_skeleton``design_content` × N → `design_refine` | Full-page designs with high fidelity |
| **CRUD** | `batch_get``update_node` / `delete_node` | Reading & modifying existing content |
## Quick Start
```bash
# Run as stdio MCP server (for Claude Desktop, Cursor, etc.)
npx @zseven-w/pen-mcp
# Or connect to a running OpenPencil instance
op mcp:dev
```
### Claude Desktop Configuration
```json
{
"mcpServers": {
"openpencil": {
"command": "npx",
"args": ["@zseven-w/pen-mcp"]
}
}
}
```
## Tools
### Document & Read Tools
| Tool | Description |
| ------------------- | --------------------------------------------------------------------------------------------------- |
| `open_document` | Open an `.op` file or connect to the live Electron canvas. Always call first. |
| `batch_get` | Search and read nodes by type, name regex, or specific IDs. Controls read depth for nested content. |
| `get_selection` | Get the currently selected nodes on the live canvas. |
| `snapshot_layout` | Get a compact bounding-box layout tree — useful for spatial understanding. |
| `find_empty_space` | Find available canvas space in a given direction for placing new content. |
| `get_design_prompt` | Retrieve segmented design knowledge (schema, layout, roles, text, style, icons, examples). |
### Node CRUD Tools
| Tool | Description |
| -------------- | ----------------------------------------------------------------------------------- |
| `insert_node` | Insert a new node with full PenNode data. Supports `postProcess` for auto-defaults. |
| `update_node` | Shallow-merge properties into an existing node. |
| `delete_node` | Delete a node and all its children. |
| `move_node` | Reparent a node to a new container. |
| `copy_node` | Deep-clone a node with new IDs under a target parent. |
| `replace_node` | Replace a node entirely with new data at the same position. |
| `import_svg` | Import a local SVG file as editable PenNodes. |
### Batch Design DSL
`batch_design` accepts a compact DSL — one operation per line:
```
root=I(null, { "type": "frame", "name": "Page", "width": 1200, ... })
header=I(root, { "type": "frame", "name": "Header", ... })
U(header, { "fill": [{ "type": "solid", "color": "#1A1A2E" }] })
logo=C("existing-logo", header, { "x": 24 })
M("floating-btn", header)
D("old-section")
```
| Op | Syntax | Description |
| ----- | ------------------------------------------ | ----------------- |
| **I** | `binding=I(parent, { data })` | Insert node |
| **U** | `U(path, { updates })` | Update properties |
| **C** | `binding=C(source, parent, { overrides })` | Copy node |
| **R** | `binding=R(path, { newData })` | Replace node |
| **M** | `M(nodeId, parent, index?)` | Move node |
| **D** | `D(nodeId)` | Delete node |
### Layered Generation Workflow
For high-fidelity multi-section designs:
```
1. design_skeleton → Create root frame + section placeholders
2. design_content → Fill each section with content nodes (call per section)
3. design_refine → Run full-tree validation and auto-fixes
```
### Page Management
| Tool | Description |
| ---------------- | ------------------------------------------ |
| `add_page` | Add a new page to the document |
| `remove_page` | Remove a page (cannot remove the last one) |
| `rename_page` | Rename a page |
| `reorder_page` | Move a page to a new index |
| `duplicate_page` | Deep-clone a page with new IDs |
### Post-Processing
All creation tools support `postProcess=true` for automatic:
- Semantic role defaults (button padding, input height, card radius, etc.)
- Icon name → SVG path resolution (Lucide icon set)
- Card row equalization in horizontal layouts
- Text height estimation
- Frame height expansion when content overflows
- `clipContent` auto-addition for frames with `cornerRadius` + images
## Design Prompt Sections
`get_design_prompt(section)` returns focused subsets of design knowledge:
| Section | Content |
| ------------ | ------------------------------------------------------------------------- |
| `schema` | PenNode type definitions and property reference |
| `layout` | Flexbox layout engine rules (gap, padding, justify, align) |
| `roles` | Semantic roles and their auto-defaults (button, input, card, navbar, ...) |
| `text` | Typography rules, CJK support, copywriting guidelines |
| `style` | Visual style policy (colors, fonts, aesthetic) |
| `icons` | Feather/Lucide icon naming conventions |
| `examples` | Complete design examples with DSL |
| `guidelines` | Design tips (cards, inputs, phone mockups, hero sections) |
| `planning` | Layered workflow guide with section decomposition rules |
## Live Canvas Sync
When connected to a running OpenPencil desktop app, changes made via MCP tools appear on the canvas in real-time. The sync is bidirectional — user edits on the canvas are reflected in subsequent `batch_get` / `snapshot_layout` calls.
## Programmatic Usage
```typescript
import { configureMcpHooks, MCP_DEFAULT_PORT } from '@zseven-w/pen-mcp';
// Configure custom hooks (optional)
configureMcpHooks({
onDocumentOpen: (path) => console.log(`Opened: ${path}`),
onNodeInsert: (node) => console.log(`Inserted: ${node.id}`),
});
```
## License
[MIT](./LICENSE)

View file

@ -1,7 +1,21 @@
{
"name": "@zseven-w/pen-mcp",
"version": "0.7.0",
"version": "0.7.1",
"description": "MCP server, document manager, and tools for OpenPencil",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-mcp",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-mcp"
},
"files": [
"src"
],

View file

@ -261,10 +261,16 @@ async function pushLiveDocument(doc: PenDocument): Promise<void> {
const syncUrl = cachedUrl ?? (await getSyncUrl());
if (!syncUrl) return;
try {
const body = JSON.stringify({ document: doc });
const bodyBytes = new TextEncoder().encode(body).byteLength;
await fetch(`${syncUrl}/api/mcp/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document: doc }),
headers: {
'Content-Type': 'application/json',
'x-openpencil-client-id': 'mcp-server:live-canvas',
'x-openpencil-body-bytes': String(bodyBytes),
},
body,
});
} catch {
// Network error — Electron might have quit between check and request

View file

@ -46,7 +46,7 @@ export const DESIGN_TOOL_DEFINITIONS = [
' binding=C(sourceId, parent, { overrides }) — Copy node\n' +
' binding=R(path, { ...newNodeData }) — Replace node\n' +
' M(nodeId, parent, index?) — Move node\n' +
' D(nodeId) — Delete node\n' +
' D(nodeId) — Delete node (use batch_get to find IDs first)\n' +
'Use null for root-level parent. Reference previous bindings by name. ' +
'Path expressions support binding+"/ childId" for nested access. ' +
'Always set postProcess=true when generating designs for best visual quality.',

View file

@ -31,7 +31,9 @@ export const DOCUMENT_TOOL_DEFINITIONS = [
{
name: 'batch_get',
description:
'Search and read nodes. With no patterns/nodeIds, returns top-level children. Search by type/name regex, or read specific IDs. ' +
'Search and read nodes from the document. ALWAYS call this first before update_node or delete_node to find the correct node IDs. ' +
'With no patterns/nodeIds, returns top-level children (use this to see the current page structure). ' +
'Search by type/name regex, or read specific IDs. ' +
'readDepth controls how deep children are included in results (default 1, use higher to see nested structure). ' +
'Returns nodes with children truncated to "..." beyond readDepth.',
inputSchema: {
@ -100,7 +102,8 @@ export const DOCUMENT_TOOL_DEFINITIONS = [
{
name: 'snapshot_layout',
description:
'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
'Get the hierarchical bounding box layout tree of the document. ' +
'Use this to understand the current page structure, spatial arrangement, and node hierarchy before making changes.',
inputSchema: {
type: 'object' as const,
properties: {

View file

@ -84,7 +84,11 @@ export const NODE_TOOL_DEFINITIONS = [
},
{
name: 'delete_node',
description: 'Delete a node (and all its children) from an .op file.',
description:
'Delete a node (and all its children) from the document. ' +
'Use this when the user asks to remove, delete, or clear specific elements. ' +
'Always call batch_get or snapshot_layout first to find the correct nodeId before deleting. ' +
'Returns confirmation of the deleted node.',
inputSchema: {
type: 'object' as const,
properties: {

View file

@ -38,9 +38,12 @@ interface OpResult {
* M(nodeId, parent, index?) Move
* D(nodeId) Delete
*/
export async function handleBatchDesign(
params: BatchDesignParams,
): Promise<{ results: OpResult[]; nodeCount: number; postProcessed?: boolean }> {
export async function handleBatchDesign(params: BatchDesignParams): Promise<{
results: OpResult[];
nodeCount: number;
postProcessed?: boolean;
errors?: Array<{ line: string; error: string }>;
}> {
const filePath = resolveDocPath(params.filePath);
let doc = await openDocument(filePath);
doc = structuredClone(doc);
@ -48,18 +51,20 @@ export async function handleBatchDesign(
const pageId = params.pageId;
const bindings = new Map<string, string>();
const results: OpResult[] = [];
const lines = params.operations
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('//'));
const errors: Array<{ line: string; error: string }> = [];
const lines = splitOperations(params.operations);
for (const line of lines) {
try {
await executeLine(line, doc, bindings, results, pageId);
} catch (err) {
throw new Error(
`Error executing "${line}": ${err instanceof Error ? err.message : String(err)}`,
);
// Best-effort: don't abort the entire batch on a single bad operation.
// Collect errors so the agent can see what failed and retry selectively.
const preview = line.length > 200 ? `${line.slice(0, 200)}...` : line;
errors.push({
line: preview,
error: err instanceof Error ? err.message : String(err),
});
}
}
@ -105,9 +110,51 @@ export async function handleBatchDesign(
results,
nodeCount: countNodes(getDocChildren(doc, pageId)),
postProcessed: postProcessed || undefined,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* Split DSL operations handling multi-line JSON bodies. Splits on newlines
* that are OUTSIDE quoted strings and balanced brackets. This lets agents
* pretty-print JSON across multiple lines inside a single operation.
*/
function splitOperations(raw: string): string[] {
const result: string[] = [];
let buf = '';
let depth = 0;
let inString = false;
let escape = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
buf += ch;
if (escape) {
escape = false;
continue;
}
if (ch === '\\' && inString) {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === '(' || ch === '[' || ch === '{') depth++;
else if (ch === ')' || ch === ']' || ch === '}') depth--;
else if (ch === '\n' && depth === 0) {
const trimmed = buf.trim();
if (trimmed && !trimmed.startsWith('//')) result.push(trimmed);
buf = '';
}
}
const tail = buf.trim();
if (tail && !tail.startsWith('//')) result.push(tail);
return result;
}
async function executeLine(
line: string,
doc: PenDocument,
@ -116,11 +163,27 @@ async function executeLine(
pageId?: string,
): Promise<void> {
// Parse: binding=OP(args) or OP(args)
// Binding is optional for I/C/R/G — auto-generated when omitted (agents
// sometimes write `I(parent, data)` without the binding prefix).
const assignMatch = line.match(/^(\w+)\s*=\s*([ICRMG])\((.+)\)$/);
const bindlessAssignMatch = !assignMatch && line.match(/^([ICRG])\((.+)\)$/);
const callMatch = line.match(/^([UDM])\((.+)\)$/);
if (assignMatch) {
const [, binding, op, argsStr] = assignMatch;
// Normalize bindless form to an auto-generated binding so the rest of the
// logic below can treat it uniformly.
const effectiveAssign =
assignMatch ??
(bindlessAssignMatch
? ([
line,
`_auto_${results.length}_${bindlessAssignMatch[1]}`,
bindlessAssignMatch[1],
bindlessAssignMatch[2],
] as RegExpMatchArray)
: null);
if (effectiveAssign) {
const [, binding, op, argsStr] = effectiveAssign;
switch (op) {
case 'I': {
const { parent, data } = parseInsertArgs(argsStr, bindings);
@ -464,22 +527,105 @@ function applyDescendantOverrides(node: PenNode, descendants: Record<string, unk
function parseJsonArg(str: string): Record<string, unknown> {
const trimmed = str.trim();
// Try strict JSON first (most common case — avoids mangling values like "Don't")
let parsed: unknown;
try {
return sanitizeObject(JSON.parse(trimmed));
parsed = JSON.parse(trimmed);
} catch {
/* fall through to lenient parsing */
}
let normalized = trimmed;
// Convert JavaScript-style object to JSON: unquoted keys → quoted
normalized = normalized.replace(/(?<=\{|,)\s*(\w+)\s*:/g, ' "$1":');
// Replace single-quoted string delimiters with double quotes (not quotes inside strings)
normalized = replaceSingleQuoteDelimiters(normalized);
try {
return sanitizeObject(JSON.parse(normalized));
} catch {
throw new Error(`Failed to parse JSON: ${str.slice(0, 200)}`);
if (parsed === undefined) {
let normalized = trimmed;
// Convert JavaScript-style object to JSON: unquoted keys → quoted
normalized = normalized.replace(/(?<=\{|,)\s*(\w+)\s*:/g, ' "$1":');
// Replace single-quoted string delimiters with double quotes (not quotes inside strings)
normalized = replaceSingleQuoteDelimiters(normalized);
// Remove empty keys like `, ": 50,` (agent truncation artifact) —
// skip the property entirely so the rest of the object still parses.
normalized = normalized.replace(/,\s*""\s*:\s*[^,}\]]+/g, '');
// Remove trailing commas before } or ] (common agent mistake)
normalized = normalized.replace(/,(\s*[}\]])/g, '$1');
try {
parsed = JSON.parse(normalized);
} catch (err) {
const snippet = str.slice(0, 300);
throw new Error(
`Failed to parse JSON (${err instanceof Error ? err.message : 'unknown'}): ${snippet}${str.length > 300 ? '...' : ''}`,
);
}
}
return sanitizeObject(normalizeNodeShape(parsed)) as Record<string, unknown>;
}
/**
* Normalize common agent shorthand into canonical PenNode format.
* Applied before sanitizeObject so downstream code sees a consistent shape.
*/
function normalizeNodeShape(input: unknown): unknown {
if (Array.isArray(input)) return input.map(normalizeNodeShape);
if (!input || typeof input !== 'object') return input;
const obj = input as Record<string, unknown>;
// fill: "#hex" | "solid#hex" → [{ type: 'solid', color: '#hex' }]
// fill: { type, color } → [{ ... }]
if ('fill' in obj) {
obj.fill = normalizeFillField(obj.fill);
}
// stroke: { thickness, color } | { thickness, color: '#hex' } →
// { thickness, fill: [{ type: 'solid', color: '#hex' }] }
// stroke: '#hex' → { thickness: 1, fill: [{ type: 'solid', color: '#hex' }] }
if ('stroke' in obj) {
obj.stroke = normalizeStrokeField(obj.stroke);
}
// Recurse into children
if (Array.isArray(obj.children)) {
obj.children = obj.children.map((c) => normalizeNodeShape(c));
}
return obj;
}
function normalizeFillField(value: unknown): unknown {
if (value == null) return value;
// '#hex' shorthand
if (typeof value === 'string') {
return [{ type: 'solid', color: value }];
}
// Single fill object → wrap in array
if (typeof value === 'object' && !Array.isArray(value)) {
return [value];
}
// Already an array — leave as-is
return value;
}
function normalizeStrokeField(value: unknown): unknown {
if (value == null) return value;
// '#hex' shorthand → stroke with solid fill
if (typeof value === 'string') {
return { thickness: 1, fill: [{ type: 'solid', color: value }] };
}
if (typeof value !== 'object') return value;
const stroke = value as Record<string, unknown>;
// { thickness, color: '#hex' } → { thickness, fill: [...] }
if (stroke.color != null && stroke.fill == null) {
stroke.fill = [{ type: 'solid', color: stroke.color }];
delete stroke.color;
}
// { fill: '#hex' | object } → { fill: [...] }
if (stroke.fill != null) {
stroke.fill = normalizeFillField(stroke.fill);
}
// { thickness, type, color } variant
if (stroke.type != null && stroke.color != null && stroke.fill == null) {
stroke.fill = [{ type: stroke.type, color: stroke.color }];
delete stroke.type;
delete stroke.color;
}
return stroke;
}
/** Replace single-quote string delimiters with double quotes, leaving apostrophes inside strings. */

View file

@ -30,9 +30,18 @@ function getSkillContent(key: string): string {
// Named prompt sections — can be retrieved individually via section parameter
// ---------------------------------------------------------------------------
const INTRO = `You are generating designs for OpenPencil, a vector design tool.
Use batch_design (for multi-node designs with DSL) or insert_node (for single node trees with JSON).
Both support postProcess=true for automatic role defaults, icon resolution, and layout sanitization.
const INTRO = `You are working with OpenPencil, a vector design tool.
TOOL SELECTION match the user's intent:
- READ/INSPECT the canvas: batch_get (search nodes, get IDs), snapshot_layout (spatial overview), get_selection (selected nodes)
- CREATE new designs: batch_design (DSL, multi-node), insert_node (JSON, single tree) both support postProcess=true
- MODIFY existing nodes: update_node (change properties), replace_node (swap entirely)
- DELETE/REMOVE elements: delete_node (remove by ID) always batch_get first to find the correct ID
- MOVE/COPY: move_node, copy_node
IMPORTANT: When the user asks to read, inspect, find, or look at existing content, use batch_get or snapshot_layout do NOT create new nodes.
When the user asks to delete or remove something, use batch_get to find it, then delete_node do NOT create new nodes.
Each node must follow the PenNode schema below.`;
const DESIGN_TYPE_DETECTION = `DESIGN TYPE DETECTION:

View file

@ -82,8 +82,12 @@ export async function handleOpenDocument(params: OpenDocumentParams): Promise<Op
context: buildDocumentContext(doc),
designPrompt: isEmpty
? buildDesignPrompt()
: 'Document has existing content. Use batch_design or insert_node with postProcess=true to add/modify designs. ' +
'For complex multi-section designs, use the layered workflow: design_skeleton → design_content (per section) → design_refine. ' +
: 'Document has existing content. Match your action to the user intent:\n' +
'- READ/INSPECT: Use batch_get (search by type/name/ID) or snapshot_layout to see what is on the canvas.\n' +
'- DELETE/REMOVE: Use batch_get to find the target node ID, then delete_node to remove it.\n' +
'- MODIFY: Use update_node to change properties of existing nodes.\n' +
'- ADD NEW: Use batch_design or insert_node with postProcess=true.\n' +
'For complex multi-section designs, use the layered workflow: design_skeleton → design_content → design_refine. ' +
'Call get_design_prompt(section="planning") for layered workflow guide, or get_design_prompt() for full guidelines.',
};
}

View file

@ -0,0 +1,73 @@
# pen-react
React UI SDK for OpenPencil -- provider, hooks, panels, toolbar, and property editor components.
## Structure
### Core
- `src/context.ts``DesignEngineContext`: React context holding the `DesignEngine` instance
- `src/provider.tsx``DesignProvider`: provides engine to React tree. Supports uncontrolled mode (`initialDocument`) and controlled mode (`document` + `onDocumentChange`) with echo-loop prevention
### Hooks (`src/hooks/`)
- `use-design-engine.ts``useDesignEngine()`: gets engine from context, throws if outside provider
- `use-document.ts``useDocument()`: returns current `PenDocument`, re-renders on mutation
- `use-selection.ts``useSelection()`: returns `string[]` of selected node IDs
- `use-viewport.ts``useViewport()`: returns `ViewportState` (zoom, panX, panY)
- `use-active-tool.ts``useActiveTool()`: returns current `ToolType`
- `use-history.ts``useHistory()`: returns `{ canUndo, canRedo }`
- `use-active-node.ts``useActiveNode()`: returns the single active `PenNode` or null
- `use-active-page.ts``useActivePage()`: returns active page ID
- `use-hover.ts``useHover()`: returns hovered node ID
- `use-variables.ts``useVariables()`: returns document variables map
### Utilities (`src/utils/`)
- `use-engine-subscribe.ts``useEngineSubscribe(engine, event, getSnapshot)`: generic hook using `useSyncExternalStore` to subscribe to engine events with stable snapshot refs
### Stores (`src/stores/`)
- `ui-store.ts``useUIStore` (Zustand): pure UI state -- panel visibility, layer drag state, collapsed nodes. NOT engine state
### Components (`src/components/`)
- `design-canvas.tsx``DesignCanvas`: canvas element with CanvasKit rendering via `attachCanvas` + `attachInteraction` from pen-engine browser adapter
- `core-toolbar.tsx``CoreToolbar`: main tool selection bar
- `tool-button.tsx``ToolButton`: individual tool button with active state
- `shape-tool-dropdown.tsx``ShapeToolDropdown`: dropdown for shape tools (rectangle, ellipse, line, polygon)
- `layer-panel.tsx``LayerPanel`: document tree panel with drag-and-drop reordering
- `layer-item.tsx``LayerItem`: individual layer row with visibility/lock toggles
- `layer-context-menu.tsx``LayerContextMenu`: right-click context menu for layers
- `property-panel.tsx``PropertyPanel`: right-side property inspector (delegates to section components)
- `color-picker.tsx``ColorPicker`: color input with hex/opacity, gradient support
- `number-input.tsx``NumberInput`: numeric input with drag-to-adjust
- `section-header.tsx``SectionHeader`: collapsible section header
- `font-picker.tsx``FontPicker`: font family selector with search
- `variable-picker.tsx``VariablePicker`: design variable selector
- `icon-picker-dialog.tsx``IconPickerDialog`: icon search and selection
- `boolean-toolbar.tsx``BooleanToolbar`: union/subtract/intersect boolean operations
- `page-tabs.tsx``PageTabs`: multi-page tab bar
- `status-bar.tsx``StatusBar`: bottom status bar (zoom, selection info)
### Property sections (`src/components/sections/`)
- `size-section.tsx` — Width, height, x, y, rotation
- `fill-section.tsx` — Fill array editor (solid, gradient, image)
- `stroke-section.tsx` — Stroke properties (thickness, alignment, dash)
- `text-section.tsx` — Text content, font, size, weight, style
- `text-layout-section.tsx` — Text alignment, growth mode
- `corner-radius-section.tsx` — Corner radius (uniform or per-corner)
- `effects-section.tsx` — Blur and shadow effects
- `layout-section.tsx` — Auto-layout direction, gap, alignment
- `layout-padding-section.tsx` — Padding (uniform or per-side)
- `appearance-section.tsx` — Opacity, blend mode, visibility
- `icon-section.tsx` — Icon font name and family
- `image-section.tsx` — Image source, fit mode, adjustments
- `export-section.tsx` — Export format and code preview
## Testing
```bash
bun --bun vitest run packages/pen-react/src/__tests__/
```

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,196 @@
# @zseven-w/pen-react
React UI SDK for [OpenPencil](https://github.com/ZSeven-W/openpencil) — a complete set of hooks, components, and panels to build a design editor with React.
## Install
```bash
npm install @zseven-w/pen-react
# or
bun add @zseven-w/pen-react
```
**Peer dependencies:** `react@^19`, `react-dom@^19`, `@radix-ui/react-*` (popover, select, separator, slider, switch, toggle, tooltip)
## Overview
`pen-react` wraps [`@zseven-w/pen-engine`](../pen-engine) into idiomatic React: a context provider, 10 semantic hooks, and 39 ready-to-use components covering the full editor UI.
```
<DesignProvider>
<CoreToolbar />
<DesignCanvas />
<LayerPanel />
<PropertyPanel />
<PageTabs />
<StatusBar />
</DesignProvider>
```
## Quick Start
```tsx
import {
DesignProvider,
DesignCanvas,
CoreToolbar,
LayerPanel,
PropertyPanel,
} from '@zseven-w/pen-react';
function Editor() {
return (
<DesignProvider initialDocument={myDoc}>
<div className="flex h-screen">
<LayerPanel />
<div className="flex flex-col flex-1">
<CoreToolbar />
<DesignCanvas className="flex-1" />
</div>
<PropertyPanel />
</div>
</DesignProvider>
);
}
```
## Hooks
All hooks subscribe to the engine and re-render on change:
```tsx
import {
useDesignEngine,
useDocument,
useSelection,
useViewport,
useActiveTool,
useHistory,
useActiveNode,
useActivePage,
useHover,
useVariables,
} from '@zseven-w/pen-react';
function Inspector() {
const node = useActiveNode(); // PenNode | null
const selection = useSelection(); // string[]
const { canUndo, undo } = useHistory();
const viewport = useViewport(); // { zoom, panX, panY }
const tool = useActiveTool(); // ToolType
const doc = useDocument(); // PenDocument
const page = useActivePage(); // PenPage
const hoverId = useHover(); // string | null
const variables = useVariables(); // VariableDefinition[]
const engine = useDesignEngine(); // DesignEngine (escape hatch)
return <div>Selected: {selection.length} nodes</div>;
}
```
## Provider
### Uncontrolled mode
Engine owns the document. Good for standalone editors:
```tsx
<DesignProvider initialDocument={doc}>{children}</DesignProvider>
```
### Controlled mode
Parent owns the document. Good for integration into existing state:
```tsx
<DesignProvider document={doc} onDocumentChange={(newDoc) => setDoc(newDoc)}>
{children}
</DesignProvider>
```
Echo-loop prevention is built in — `onDocumentChange` won't fire for changes that originated from the parent.
## Components
### Canvas
| Component | Description |
| -------------- | ----------------------------------------------------------------------------------------- |
| `DesignCanvas` | GPU-rendered canvas with CanvasKit/Skia. Handles zoom, pan, resize, and all interactions. |
```tsx
<DesignCanvas
className="w-full h-full"
onReady={(engine) => console.log('Canvas ready')}
loadingFallback={<Spinner />}
/>
```
### Toolbar
| Component | Description |
| ------------------- | ----------------------------------------------------------------- |
| `CoreToolbar` | Main tool selection bar (select, frame, shapes, text, pen, image) |
| `ToolButton` | Individual tool button with icon + active state |
| `ShapeToolDropdown` | Dropdown for shape tools (rectangle, ellipse, polygon, line) |
| `BooleanToolbar` | Union, subtract, intersect, exclude operations |
### Panels
| Component | Description |
| ------------------ | ----------------------------------------------------- |
| `LayerPanel` | Hierarchical tree view with drag-and-drop reordering |
| `LayerItem` | Single layer row — collapse, visibility, lock, rename |
| `LayerContextMenu` | Right-click menu: copy, paste, delete, group, z-order |
| `PropertyPanel` | Tabbed property inspector for the selected node |
| `PageTabs` | Multi-page tab bar with add/rename/reorder/delete |
| `StatusBar` | Bottom bar with zoom, coordinates, node count |
### Property Sections
Drop these into your own property panel or use `PropertyPanel` which includes all of them:
| Section | Edits |
| ---------------------- | ----------------------------------------------- |
| `SizeSection` | x, y, width, height, rotation, constraints |
| `FillSection` | Solid color, linear/radial gradient |
| `StrokeSection` | Color, thickness, dash pattern, cap, join |
| `TextSection` | Font family, size, weight, color, alignment |
| `TextLayoutSection` | Line height, letter spacing, paragraph spacing |
| `CornerRadiusSection` | Uniform or per-corner border radius |
| `EffectsSection` | Drop shadow, inner shadow, blur |
| `LayoutSection` | Auto-layout direction, gap, justify, align |
| `LayoutPaddingSection` | Uniform or per-side padding |
| `AppearanceSection` | Opacity, blend mode |
| `IconSection` | Icon name picker (Lucide icons) |
| `ImageSection` | Image source, fit mode |
| `ExportSection` | Code generation target (React, HTML, Vue, etc.) |
### Shared UI
| Component | Description |
| ------------------ | --------------------------------------------------- |
| `ColorPicker` | Color input with swatch palette and hex input |
| `NumberInput` | Numeric field with drag-to-adjust and arrow keys |
| `SectionHeader` | Collapsible section header with title + actions |
| `FontPicker` | Font family selector with preview |
| `VariablePicker` | Design variable reference picker (`$primary`, etc.) |
| `IconPickerDialog` | Modal icon browser with search and categories |
## UI Store
Ephemeral UI state (panel open/close, drag state) managed by Zustand — separate from engine state:
```tsx
import { useUIStore } from '@zseven-w/pen-react';
const { layerPanelOpen, toggleLayerPanel } = useUIStore();
```
## Styling
Components use [Tailwind CSS](https://tailwindcss.com/) + [CVA](https://cva.style/docs) for variant styling, and [Radix UI](https://www.radix-ui.com/) primitives for accessibility. Override styles via `className` props or Tailwind's design token system.
## License
[MIT](./LICENSE)

View file

@ -1,7 +1,21 @@
{
"name": "@zseven-w/pen-react",
"version": "0.7.0",
"version": "0.7.1",
"description": "React UI SDK for OpenPencil — hooks, components, and state bridges for pen-engine",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-react",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-react"
},
"files": [
"src"
],

View file

@ -0,0 +1,31 @@
# pen-renderer
Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files. No React, no Zustand -- pure TypeScript + CanvasKit WASM.
## Structure
- `src/renderer.ts``PenRenderer`: high-level read-only renderer. Methods: `init(canvas)`, `setDocument(doc)`, `render()`, `zoomToFit()`, `zoomTo(zoom, x, y)`, `pan(dx, dy)`, `hitTest(x, y)`, `dispose()`. Manages viewport, render nodes, spatial index, and frame labels
- `src/document-flattener.ts` — Tree flattening with layout resolution: `flattenToRenderNodes` (recursive walk producing `RenderNode[]` with absolute positions), `resolveRefs` (ref node resolution), `remapIds`, `premeasureTextHeights` (Canvas 2D text measurement for accurate heights), `collectReusableIds`, `collectInstanceIds`
- `src/node-renderer.ts``SkiaNodeRenderer`: core draw calls for all node types (rectangles, ellipses, paths, text, images, icons, lines, polygons). Handles fills (solid, gradient, image), strokes, effects (shadow, blur), corner radius, clip content, opacity, blend mode
- `src/text-renderer.ts``SkiaTextRenderer`: text rendering sub-system with both vector (Paragraph API) and bitmap (Canvas 2D rasterization) paths. FIFO caches with byte-based eviction limits (256 MB text, 64 MB paragraph)
- `src/paint-utils.ts` — Color/paint utilities: `parseColor` (hex to CanvasKit Color4f), `cornerRadiusValue`, `cornerRadii`, `resolveFillColor`, `resolveStrokeColor`, `resolveStrokeWidth`, `wrapLine`, `cssFontFamily`
- `src/path-utils.ts` — SVG path utilities: `sanitizeSvgPath` (normalize for CanvasKit parser), `hasInvalidNumbers`, `tryManualPathParse`
- `src/image-loader.ts``SkiaImageLoader`: async image loading with browser Image element, Canvas 2D rasterization, CanvasKit Image conversion, and caching. Supports custom source resolvers
- `src/font-manager.ts``SkiaFontManager`: font management with bundled fonts (Inter, Poppins, Roboto, etc.) and Google Fonts CSS fetching. `BUNDLED_FONT_FAMILIES` constant
- `src/spatial-index.ts``SpatialIndex`: R-tree backed (rbush) spatial queries for hit testing. Methods: `rebuild`, `hitTest`, `searchRect`, `get`. Returns results topmost-first by render order
- `src/viewport.ts` — Viewport math: `viewportMatrix` (3x3 transform for CanvasKit), `screenToScene`, `sceneToScreen`, `zoomToPoint`, `getViewportBounds`, `isRectInViewport`
- `src/init.ts``loadCanvasKit(options)`, `getCanvasKit()`: singleton CanvasKit WASM loader
- `src/render-node-thumbnail.ts``renderNodeThumbnail(node, context)`: offscreen thumbnail helper for individual nodes (used by git conflict UI). Returns data URL or null
- `src/types.ts``RenderNode` (node + absolute bounds + clip rect), `PenRendererOptions`, `IconLookupFn`
## Key exports
Primary: `loadCanvasKit`, `getCanvasKit`, `PenRenderer`
Low-level (for editor re-use): `SkiaNodeRenderer`, `SkiaTextRenderer`, `SkiaFontManager`, `SkiaImageLoader`, `SpatialIndex`, `flattenToRenderNodes`, `resolveRefs`, `premeasureTextHeights`, `viewportMatrix`, `screenToScene`, `sceneToScreen`, `zoomToPoint`, `parseColor`, `sanitizeSvgPath`, `renderNodeThumbnail`
## Testing
```bash
bun --bun vitest run packages/pen-renderer/src/__tests__/
```

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,64 +1,190 @@
# @zseven-w/pen-renderer
Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/nicepkg/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/ZSeven-W/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
## Install
```bash
npm install @zseven-w/pen-renderer canvaskit-wasm
# or
bun add @zseven-w/pen-renderer canvaskit-wasm
```
`canvaskit-wasm` is a peer dependency — you provide the WASM binary.
## Usage
## Overview
```ts
`pen-renderer` is a pure TypeScript + CanvasKit rendering pipeline with no React or framework dependency. It takes a `PenDocument` and renders it to a WebGL surface with GPU acceleration. The pipeline:
```
PenDocument → flattenToRenderNodes() → absolute positions → SkiaNodeRenderer → GPU canvas
SpatialIndex (R-tree) → hitTest / searchRect
```
## Quick Start
```typescript
import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer';
// Initialize CanvasKit
// 1. Initialize CanvasKit WASM (once, globally)
await loadCanvasKit();
// Create renderer on a canvas element
// 2. Create renderer on a canvas element
const renderer = new PenRenderer(canvas, document, {
width: 1920,
height: 1080,
});
// Render
// 3. Render
renderer.render();
// 4. Interact
renderer.zoomToFit();
renderer.zoomTo(1.5, centerX, centerY);
renderer.pan(deltaX, deltaY);
const node = renderer.hitTest(mouseX, mouseY);
// 5. Cleanup
renderer.dispose();
```
## API
## Features
### High-level
### High-Level Renderer
- **`loadCanvasKit()`** — Initialize the CanvasKit WASM module
- **`PenRenderer`** — Full-featured renderer with viewport, selection, and interaction support
`PenRenderer` provides a complete rendering solution with viewport, selection, and interaction:
```typescript
const renderer = new PenRenderer(canvas, document, options);
renderer.setDocument(newDoc); // Update document
renderer.render(); // Trigger re-render
renderer.zoomToFit(); // Fit content to viewport
renderer.zoomTo(zoom, cx, cy); // Zoom to point
renderer.pan(dx, dy); // Pan viewport
renderer.hitTest(x, y); // Hit test at screen coords
renderer.dispose(); // Free resources
```
### Document Flattening
Pre-process documents for rendering:
Pre-process the document tree into flat render nodes with absolute positions:
```ts
import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer';
```typescript
import {
flattenToRenderNodes,
resolveRefs,
premeasureTextHeights,
remapIds,
} from '@zseven-w/pen-renderer';
// Flatten tree → absolute positions
const renderNodes = flattenToRenderNodes(children, viewport);
// Resolve $ref nodes to their source
const resolved = resolveRefs(renderNodes, document);
// Pre-measure text heights using Canvas 2D (for accurate layout)
premeasureTextHeights(renderNodes, canvasContext);
```
### Viewport Utilities
### Viewport Math
```ts
import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer';
Camera transforms for pan, zoom, and coordinate conversion:
```typescript
import {
viewportMatrix,
screenToScene,
sceneToScreen,
zoomToPoint,
getViewportBounds,
isRectInViewport,
} from '@zseven-w/pen-renderer';
const matrix = viewportMatrix(zoom, panX, panY); // 3x3 CanvasKit matrix
const scene = screenToScene(mouseX, mouseY, viewport);
const screen = sceneToScreen(nodeX, nodeY, viewport);
const newVp = zoomToPoint(viewport, 2.0, centerX, centerY);
```
### Low-level Renderers
### Spatial Index
R-tree backed spatial queries for click hit testing and marquee selection:
```typescript
import { SpatialIndex } from '@zseven-w/pen-renderer';
const index = new SpatialIndex();
index.rebuild(renderNodes);
const clicked = index.hitTest(x, y); // topmost node at point
const selected = index.searchRect(x, y, w, h); // all nodes in rect
const node = index.get(nodeId); // lookup by ID
```
### Low-Level Renderers
For custom rendering pipelines:
- `SkiaNodeRenderer` — Renders individual nodes to a Skia canvas
- `SkiaTextRenderer` — Text layout and rendering
- `SkiaFontManager` — Font loading and management
- `SkiaImageLoader` — Async image loading with caching
- `SpatialIndex` — R-tree spatial index for hit testing
```typescript
import {
SkiaNodeRenderer,
SkiaTextRenderer,
SkiaFontManager,
SkiaImageLoader,
} from '@zseven-w/pen-renderer';
```
| Class | Handles |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SkiaNodeRenderer` | All node types — rectangles, ellipses, paths, images, icons, lines, polygons. Fills (solid, gradient, image), strokes, effects (shadow, blur), corner radius, clip, opacity, blend mode |
| `SkiaTextRenderer` | Text layout and rendering via Paragraph API with bitmap fallback. FIFO caches (256 MB text, 64 MB paragraph) |
| `SkiaFontManager` | Font loading — bundled fonts (Inter, Poppins, Roboto, etc.) + Google Fonts CSS fetching |
| `SkiaImageLoader` | Async image loading with caching and custom source resolvers |
### Thumbnail Generation
Render individual nodes to offscreen thumbnails (used for git conflict UI, exports):
```typescript
import { renderNodeThumbnail } from '@zseven-w/pen-renderer';
const dataUrl = renderNodeThumbnail(node, { width: 200, height: 200 });
```
### Paint Utilities
```typescript
import {
parseColor,
resolveFillColor,
resolveStrokeColor,
wrapLine,
cssFontFamily,
sanitizeSvgPath,
} from '@zseven-w/pen-renderer';
const color = parseColor('#2563EB'); // CanvasKit Color4f
```
## API Reference
| Category | Exports |
| ------------- | -------------------------------------------------------------------------------------- |
| **Init** | `loadCanvasKit(options?)`, `getCanvasKit()` |
| **Renderer** | `PenRenderer` |
| **Flatten** | `flattenToRenderNodes`, `resolveRefs`, `premeasureTextHeights`, `remapIds` |
| **Viewport** | `viewportMatrix`, `screenToScene`, `sceneToScreen`, `zoomToPoint`, `getViewportBounds` |
| **Spatial** | `SpatialIndex``rebuild`, `hitTest`, `searchRect`, `get` |
| **Node** | `SkiaNodeRenderer` |
| **Text** | `SkiaTextRenderer` |
| **Font** | `SkiaFontManager`, `BUNDLED_FONT_FAMILIES` |
| **Image** | `SkiaImageLoader` |
| **Paint** | `parseColor`, `sanitizeSvgPath`, `cssFontFamily` |
| **Thumbnail** | `renderNodeThumbnail` |
## License
MIT
[MIT](./LICENSE)

View file

@ -1,7 +1,21 @@
{
"name": "@zseven-w/pen-renderer",
"version": "0.7.0",
"version": "0.7.1",
"description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-renderer",
"bugs": {
"url": "https://github.com/ZSeven-W/openpencil/issues"
},
"license": "MIT",
"author": {
"name": "ZSeven-W",
"email": "xkayshen@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ZSeven-W/openpencil.git",
"directory": "packages/pen-renderer"
},
"files": [
"src"
],

View file

@ -0,0 +1,29 @@
# pen-sdk
Umbrella SDK that re-exports all OpenPencil packages from a single entry point.
## Structure
- `src/index.ts` — Single barrel file re-exporting from:
- `@zseven-w/pen-types` — All document model types and codegen types
- `@zseven-w/pen-core` — Tree operations, layout engine, variables, normalization, boolean ops
- `@zseven-w/pen-engine``DesignEngine` and all managers
- `@zseven-w/pen-react` — All hooks, components, and stores (`export *`)
- `@zseven-w/pen-renderer``PenRenderer`, CanvasKit loader, low-level rendering utilities
- `@zseven-w/pen-figma` — Figma file parser and converter
## Usage
```ts
import {
type PenDocument,
createEmptyDocument,
DesignEngine,
DesignProvider,
useDocument,
PenRenderer,
parseFigFile,
} from '@zseven-w/pen-sdk';
```
Consumers can import from `@zseven-w/pen-sdk` instead of individual packages. All types, runtime exports, and React hooks are available.

21
packages/pen-sdk/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZSeven—W
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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