openpencil/crates/CLAUDE.md
Kayshen-X a172b74023 refactor(host): move chat_intent to op-web-daemon; extract host-coupled test (Phase 5, Task 5.3a)
The CLI standard-mode intent router moves to op_web_daemon::chat_intent; its 23
headless tests (which reach chat_intent's #[cfg(test)] internals) move with it as the
#[path] sibling. The 1 host-coupled test (cli_new_design_clears_agent_frame_indicators_after_done
— drives the GUI design-session pumps via WidgetHostNative) is extracted to
op-host-desktop/src/chat_intent_host_tests.rs against the pub run_cli_turn/CliTurnPlan API +
crate::design_session pumps. chat_session/chat_session_launch/web_chat_standard refs repointed.
op-web-daemon 276 + host test 1 green; no dep/lock change.
2026-06-19 21:55:33 +08:00

533 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Rust Shell
Native + web editor chrome implemented in Rust against jian-skia. Goal: TS-equivalent editor UI surface so the backend can later swap underneath without UI regressions.
## Crate layout
> **Phase 7.3 reorg — read before trusting paths below.** The old
> `openpencil-shell-*` crates were dissolved/renamed; deeper sections in this doc
> may still cite pre-reorg paths. Map them: `openpencil-shell-native` →
> **`op-host-native`**, `openpencil-shell-web` → **`op-host-web`**,
> `openpencil-desktop` → **`op-host-desktop`**. `openpencil-shell-core` was
> **split**, not just renamed: widgets / Document / `RenderBackend` →
> **`op-editor-ui`**, the MCP wire layer → **`op-mcp`** (flat `op-mcp/src/*.rs`,
> no `mcp/` subdir). The `wasm-libc-shim` + `vendor/skia-safe-op` wasm-skia fork
> are **retired** — the browser renders through the official CanvasKit skia WASM
> (loaded separately), so the Rust wasm bundle is pure logic; the **"Web runner"
> section below is stale** (it describes the old wasm32 skia-raster + EMSDK path).
```
crates/
├── op-editor-ui/ Platform-free widgets + Document model + RenderBackend trait (wasm32-clean)
├── op-editor-core/ Canonical `.op` (PenDocument) editor state + EditorCommand
├── op-editor-host-core/ Transport-free host state machines shared by all hosts
├── op-host-native/ Native host lib: WidgetHostNative + skia-safe GL backend (desktop + mobile)
├── op-host-web/ Browser bundle entry: wasm32-unknown-unknown cdylib, CanvasKit renderer
├── op-host-desktop/ Desktop binary `openpencil-desktop` (winit + skia-safe GL); also the `--serve-web` daemon
├── op-cli/ `op` command-line tool
└── … op-mcp / op-ai / op-ai-skills / op-codegen / op-orchestrator / op-figma /
op-git / op-opmerge / op-pen-loader / op-design-lint / op-config-store /
op-process-io / op-acp / op-i18n / op-rpc-transport / op-smoke
```
`vendor/jian/` is a submodule providing the rendering primitive layer (`jian-skia` Skia adapter, `jian-host-desktop` GL plumbing, `jian-core` event types + taffy layout); `vendor/casement` is the winit fork; `vendor/agent` is the cross-product Rust agent runtime. All are referenced by path in the workspace Cargo.toml.
## Key invariants
- **op-editor-ui (widgets) stays wasm32-clean** (spec v19 §1.2). No skia-safe / winit / accesskit_winit. The `RenderBackend` trait is the only seam between widget code and platform.
- **Widget code lives in op-editor-ui only.** Hosts (op-host-native `widget_host.rs`, op-host-web `widget_host.rs`) are the ONLY files allowed to call `op_editor_ui::widgets::*`. Boundary script: `tools/check-widget-boundary.sh`.
- **Max 800 lines per file** — same rule as the TS workspace. `property_panel.rs` is split into 5 files (see PropertyPanel row in the widget table). `widget_host.rs` (shell-native) is split into a slim spine + 8 sibling submodules under `widget_host/` (see "Native widget_host layout" below). `document.rs` is split into a spine + sibling submodules under `document/` — types in `document.rs`, `impl Document` in `mutators.rs`, free walkers + `ReorderDirection` in `walkers.rs`, chat types in `chat.rs`, pen-tool ops in `pen.rs`, color-picker state in `color_picker.rs`, group/ungroup ops in `grouping.rs`, page CRUD in `page_mutators.rs`, tests split into `tests_mutators.rs` + `tests_geometry.rs`.
- **Web bundle ceiling: 6 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync), so the ceiling sits well above the retired skia raster path's 1 MiB (~4.5 MiB today). The 0 env.\* guard still holds — CanvasKit needs no libc shim.
## Document model (`shell-core/src/document/`)
Single source of truth for editor state — mirrors TS `useCanvasStore` + `useDocumentStore` + `useAIStore` collapsed into one.
```text
Document
├── pages: Vec<Page> (id + name + nodes)
├── active_page_index
├── selected: NodeId (NONE = no selection)
├── tool: Tool (Select / Rect / Ellipse / Polygon / Line / Pen / Text / Frame / Hand)
├── viewport: Viewport (pan_x / pan_y / zoom + zoom_at + pan)
├── chat: ChatState (messages, input, focused, anchor, collapsed)
└── ui: UiState (sidebar_open, layer_panel_width, property_panel_width,
property_focus, property_input: TextInputState,
settings_input: TextInputState,
agent_settings_open, agent_settings (focus, tab, connected[5],
mcp_server, mcp_cli_enabled[6], images_*, hover_provider),
color_picker, pen_in_progress, pen_cursor_doc,
pending_pen_history,
layer_context_menu, page_context_menu,
theme_mode, locale, locale_picker_open,
shape_picker_open, shape_tool,
flex_layout, size_fill_width / fill_height / hug_width /
hug_height / clip_content, fill_type, fill_type_picker_open,
property_tab (Design|Code))
```
Mutators on `Document`:
- `commit_property_edit(focus, value)` — write parsed f32 to position / size / rotation / stroke width.
- `set_selected_color(is_fill, color)` — write hex-parsed `Color` to fill or stroke.
- `set_selected_bounds(rect)` — handle-drag resize.
- `set_selected_rotation(radians)` — rotation-ring drag.
- `translate_selected(dx, dy)` — node-drag move (recurses into descendants when the matched node is bounded so children don't detach).
- `delete_selected()` — remove the selected node from its parent's children (Delete / Backspace shortcut).
- `duplicate_selected(&mut next_id, offset_doc_px)` — deep-clone with fresh ids; lifts the allocator past `max_node_id() + 1` (`checked_add` so `u64::MAX` returns None instead of colliding).
- `reorder_selected(ReorderDirection::Up | Down)` — swap with next/prev sibling (`[` / `]`).
- `deselect_all()` — clear selection (Escape last tier).
- `max_node_id()` — largest raw id across pages + children, for the duplicate allocator guard.
- `node_at_doc_point(p)` — top-most-first hit-test honoring per-node rotation.
- `start_pen_path` / `add_pen_point` / `finish_pen_path` (`document/pen.rs`) — multi-anchor `NodeKind::Path` builder; history snapshot captured BEFORE the first anchor so undo restores pre-pen state; finish strips 1-anchor (invisible) paths instead of polluting the undo stack.
- `group_selected` / `ungroup_selected` (`document/grouping.rs`) — Cmd+G / Cmd+Shift+G; group wraps selected siblings under a fresh `NodeKind::Group` whose `aggregate_bounds` covers the union.
- `add_page` / `duplicate_page` / `remove_page` / `rename_page_committed` (`document/page_mutators.rs`).
- `open_color_picker(target, click_y)` (`document/color_picker.rs`) — anchors a floating HSV picker; HSV stays anchored across the RGB-rounding cycle so dragging the hue slider doesn't visibly snap.
`Node.rotation: f32` (radians, cw +); paint applies `RenderBackend::rotate(radians, pivot)` around the node's centre. Bounded-Frame drag carries descendants — children's bounds are document-space-absolute. `Node.corner_radius: f32` (doc-px) is honored by Rect / Frame paint via `fill_round_rect` / `stroke_round_rect` when `radius * zoom > 0.5`; below that threshold paint collapses to `fill_rect` / `stroke_rect`.
`Node::aggregate_bounds` returns child-union bounds for container nodes (Group / unbounded Frame) so the property panel reports meaningful W/H.
`NodeKind` now spans Frame / Group / Rect / Ellipse / Polygon / Line / Text / Path / Other; each has its own canvas paint (oval, triangle polygon, diagonal line, fill+stroke rect, polyline through `node.points`, draw_str). The `RenderBackend` trait grew `fill_oval` / `stroke_oval` / `fill_polygon` / `stroke_polygon` / `rotate` / `fill_svg_path` so both native + web backends can paint them; `fill_svg_path` covers brand logos that ship as filled SVG paths in a non-24×24 viewBox.
`FillType { Solid, LinearGradient, RadialGradient, Image }` + `FlexLayout { Free, Vertical, Horizontal }` drive the property panel's dropdowns / button groups; both live on `Document.ui` so toggles persist across selection changes.
## Widgets (`shell-core/src/widgets/`)
| Widget | Section | File |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| TopBar | Top — file name, agent chip, theme/i18n/fullscreen, sidebar toggle | `top_bar.rs` |
| LayerPanel | Left rail — Pages + Layers sections | `layer_panel.rs` |
| Toolbar | Vertical floating column — Select / shape slot / Text / Frame / Hand | `toolbar.rs` |
| ShapePicker | Toolbar shape-slot dropdown (Rect / Ellipse / Polygon / Line / Pen / Icon / Import) | `shape_picker.rs` |
| CanvasViewport | Center — node tree + grid + viewport transform | `canvas_viewport.rs` |
| PropertyPanel | Right rail — 设计/代码 tabs + 10 sections + interactive inputs (X/Y/W/H/R, hex, stroke width) + flex/size toggles + fill-type picker | `property_panel.rs` + `property_panel_sections.rs` + `property_panel_inputs.rs` + `property_panel_layout.rs` + `property_panel_fill.rs` |
| AIChatPlaceholder | Floating — chat with drag + 4-corner snap + collapse pill | `ai_chat_panel.rs` |
| LocalePicker | TopBar Globe-button dropdown (15 native names + Check) | `locale_picker.rs` |
| StatusBar | Floating bottom-right — zoom controls | `status_bar.rs` |
| icons | lucide d-string library (50+ icons; 24×24 viewBox stroke art) | `icons.rs` |
| brand_icons | Claude / OpenAI / Gemini / Copilot / OpenCode brand logos (filled SVG paths, non-24×24 viewBoxes) | `brand_icons.rs` |
| ColorPicker | HSV overlay (Cmd-Shift-C or fill/stroke swatch click) — sat/value box + hue strip + hex input | `color_picker.rs` |
| LayerContextMenu | Right-click overlay on layer rows + page tabs (Rename / Duplicate / Delete / Group / Ungroup / Lock / Hide; subset on page tabs) | `layer_context_menu.rs` |
| AgentSettings | Cmd+, modal — 880×640 with sidebar nav (Agents / MCP / Images / System) + scrollable right pane | `agent_settings_panel.rs` + `agent_settings_{i18n,images,mcp,system}.rs` |
| theme | shadcn-dark palette tokens (incl. `canvas_surface`) | `theme.rs` |
| i18n | 15 locale tables (706 keys each, TS-mirrored) — extracted into the `op-i18n` crate | `op-i18n/src/i18n/{en,zh_cn,zh_tw,ja,ko,fr,es,de,pt,ru,hi,tr,th,vi,id}.rs` |
## Theme + i18n
`Document.ui` carries chrome-level state including `theme_mode` (Dark/Light), `locale`, and `locale_picker_open`:
- `Document::theme()` returns the active `Theme`. Widget builders read it instead of hardcoding `Theme::dark()`, so flipping the TopBar Sun icon reflows the entire chrome.
- `Document::t(key)` translates via `i18n::translate(self.ui.locale, key)`. Keys follow the TS `apps/web/src/i18n/locales/*.ts` dot.case convention (`common.untitled`, `pages.title`, `layers.title`, `ai.newChat`, `ai.tipSelectElements`, `rightPanel.design`, `layout.flexLayout`, `fill.title`, `stroke.title`, `effects.title`, `export.title`, `property.createComponent`, `topbar.agentsAndMcp`).
- 15 supported locales (matches TS dropdown order): EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr / Th / Vi / Id. Each carries a `display_name()` (English / 简体中文 / 繁體中文 / 日本語 / 한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी / Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia).
- TopBar Globe-button is a 44 px-wide compound (globe + chevron-down) opening a `LocalePicker` dropdown — clicking a row sets `Document.ui.locale` and closes; clicking outside (or the Globe again) closes silently. The picker paints as the top-most overlay so it covers chat / status / canvas.
- Multi-script chrome strings (한국어 / हिन्दी / ไทย / Tiếng Việt) render against per-codepoint typeface lookups (`FontMgr::match_family_style_character` cached per `i32` in `NativeBackend`), with each string broken into contiguous-typeface segments before draw.
- Tables are generated by `tools/convert-locales.py`. Re-run after changing TS locales:
```sh
python3 tools/convert-locales.py
```
Each locale file is ≤ 730 lines (under the 800-line ceiling). Cross-locale fallback: missing keys try EN before falling through to the key itself for debug visibility.
## Toolbar shape-tool dropdown
The toolbar's compound `ShapeSlot` paints whichever shape variant is current (`ui.shape_tool`, default `Rect`) plus a small chevron-down in the gutter directly below the button (`SHAPE_SLOT_BOTTOM_EXTRA = 10 px`). Click anywhere on the slot — including the chevron — to toggle `ui.shape_picker_open`.
`ShapePicker::for_document(doc)` paints a 220 × 7-row dropdown anchored to the right of the slot. The seven rows mirror the TS shape-tool-dropdown verbatim:
- Rectangle / Ellipse / Polygon / Line / Pen → `ShapeChoice::Tool(Tool::*)` — the host writes `ui.shape_tool` + `doc.tool` and closes the panel.
- Icon → `ShapeChoice::OpenIconPicker` (host follow-up).
- Import Image or SVG… → `ShapeChoice::ImportImageOrSvg` (host follow-up).
Click anywhere outside the panel closes it silently. Locale lookups for the row labels (`shapes.rectangle / ellipse / polygon / line / icon / importImageSvg / pen`) come straight from the TS table; missing keys fall back to English literals.
## PropertyPanel input editing
`Document.ui` carries the focused property field and a shared text input state:
- `property_focus: Option<PropertyFocus>``PositionX / PositionY / Rotation / PositionR / SizeW / SizeH / Opacity / FillHex / StrokeHex / StrokeWidth`. **All 10 variants are wired end-to-end:** numeric focuses go through `Document::commit_property_edit` (`PositionR` writes `node.corner_radius`), hex focuses through `set_selected_color(is_fill, color)`.
- `property_input: TextInputState` — live keystrokes, caret, select-all, blink, and composition state accumulate here. `apply_text` is focus-aware:
- Numeric focuses (Position / Size / Rotation / Opacity / StrokeWidth) gate `[0-9]`, leading `-`, and a single `.`.
- Hex focuses (FillHex / StrokeHex) preserve a sticky `#` prefix, accept `[0-9a-fA-F]` only, and cap the draft at 7 chars (`#RRGGBB`). No select-all-on-focus — backspace removes one char at a time, typing appends one.
- `property_input_draft` / caret fields are legacy mirrors while older preset-name and compatibility paint paths are retired.
Hex parsing is forgiving: `parse_hex_color` zero-pads 1-5 char inputs to 6 and expands CSS shorthand `#RGB``#RRGGBB`, so mid-edit commits don't visibly "reset" the colour.
`PropertyPanel::for_selection_at(doc, now_ms)` is the entry point. The host calls `panel.hit_test(panel_rect, point)` to map clicks onto a `PropertyFocus`, and `panel.hit_test_action(panel_rect, point)` to map clicks onto a `PropertyPanelAction`. Commit on Enter, discard on Escape, auto-commit on click outside the property panel.
### Buttons + checkboxes — `PropertyPanelAction`
```
PropertyPanelAction
├── SetFlexLayout(FlexLayout) Free / Vertical / Horizontal
├── ToggleSizeFillWidth / FillHeight
├── ToggleSizeHugWidth / HugHeight
├── ToggleSizeClipContent
├── ToggleFillTypePicker head-row dropdown
└── SetFillType(FillType) Solid / LinearGradient / RadialGradient / Image
```
The hit-test walker `action_button_rects_with_fill_picker(panel_rect, visible, fill_picker_open)` lives in `property_panel_layout.rs` and emits one `Rect` per action. Same y-walk math as `editable_input_rects` so paint + hit-test stay in sync regardless of which sections are filtered.
### Fill-type dropdown
`FillType { Solid, LinearGradient, RadialGradient, Image }` lives on `Document.ui`. The Fill section head row paints `<swatch> <type-label ▾> <opacity%> <X>`; clicking the label opens an overlay popover with 4 rows. Body branches per type:
- **Solid** — hex input + caret.
- **LinearGradient** — Angle row + 色标 header + 2 default stops.
- **RadialGradient** — 色标 header + 2 stops (no angle).
- **Image** — 填充 row.
`fill_body_height(fill_type)` in `property_panel_layout.rs` returns the body height per variant; layout walkers thread it through `VisibleSections { …, fill_type }` so sections after Fill stay aligned with paint when the user flips type. Outside clicks close the picker via a dedicated swallow branch in `apply_press`, above all other property-panel hit-tests.
### Per-NodeKind section filtering
`SectionCapabilities::for_kind(NodeKind)` returns which sections paint for the current selection (Frame omits Stroke, Text omits Effects/Export, etc.). The returned `VisibleSections` is threaded through every paint routine _and_ both layout walkers so hidden sections cause subsequent rects to shift up by the right amount.
### File split
`property_panel_sections.rs` was split into 5 files to honor the 800-line ceiling:
- `property_panel.rs``PropertyPanel`, snapshot, `SectionCapabilities`, hit-test entry points.
- `property_panel_sections.rs` — section paint routines + `PropertyLabels` + `EditContext`.
- `property_panel_inputs.rs` — shared paint helpers (label / divider / input variants), layout constants, `format_color_hex`, `to_jian_color`.
- `property_panel_layout.rs``VisibleSections` / `SizeFlags` / `fill_body_height` + the two layout walkers.
- `property_panel_fill.rs` — fill-type label table, picker overlay, head row, all 4 body variants.
`PropertyLabels::for_document(doc)` resolves every section title (位置/弹性布局/尺寸/图层/填充/描边/效果/导出), the 设计/代码 tabs, the 创建组件 button, and the size checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) via `Document::t`, falling back to English when a key isn't in the TS locale table.
## RenderBackend trait
```rust
fill_rect / stroke_rect / draw_text / clip_rect
save / restore / translate
stroke_line / fill_round_rect / stroke_round_rect / stroke_svg_path
resize / dpi_scale
```
`stroke_svg_path` parses lucide d-strings via `skia_safe::utils::parse_path::from_svg`. PaintCap::Round + PaintJoin::Round to match lucide's stroke style.
## Native widget_host layout
`crates/op-host-native/src/widget_host.rs` is a slim spine (~265 lines) holding the public surface — `WidgetHostNative` struct, drag-state structs, `CursorHint` enum, `PanelResizeKind` enum, the constructor and tiny accessors (`set_now_ms` / `chat_focused` / `next_animation_deadline_ms`) — plus `mod` declarations for the sibling submodules under `widget_host/`:
| File | Purpose |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `widget_host/frame_backend.rs` | `NativeFrameBackend` (`RenderBackend` impl over `NativeBackend` + `&Canvas`) |
| `widget_host/helpers.rs` | `parse_hex_color` / `color_to_hex` / `rect_contains` / `resize_bounds` + the inset / gutter / width constants |
| `widget_host/geometry.rs` | `impl WidgetHostNative` — canvas-region / panel-resize hover / cursor hint / picker rect math |
| `widget_host/input.rs` | `impl WidgetHostNative``apply_wheel` / `_pan_gesture` / `_cursor_move` / `_release[_with_viewport]` / `_text` / `_backspace` / `_send` / `_escape` / `_property_action` / `commit_property_focus_if_any` / `_click` |
| `widget_host/press.rs` | `impl WidgetHostNative``apply_press` + `create_node_for_active_tool` (largest single method; routes through 10 hit-test layers) |
| `widget_host/paint.rs` | `impl WidgetHostNative::paint` — full editor-UI composition pass |
### Keyboard shortcuts
Native (`openpencil-desktop`) + web (`shell-web`) both dispatch the following P1 keyboard shortcuts through `WidgetHostNative` / `WidgetHost` methods. The desktop runner reads modifier state from `WindowEvent::ModifiersChanged` (`zoom_modifier` = Cmd/Ctrl, `shift_modifier` = Shift); the web shell reads `evt.meta_key() || evt.ctrl_key()` and `evt.shift_key()` from `KeyboardEvent`.
| Key | Method | Behaviour (TS parity: `use-edit-shortcuts.ts` + `use-clipboard-shortcuts.ts`) |
| ------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Backspace` | `apply_backspace` | Pops a char when an input is focused; else `delete_selected()`. |
| `Delete` | `apply_delete` | `delete_selected()` regardless of which non-text overlay is open. |
| `Cmd/Ctrl+D` | `apply_duplicate` | `duplicate_selected(&mut next_node_id, 10.0)` and selects the clone. |
| `ArrowUp/Down/Left/Right` | `apply_nudge(dx, dy)` | Translates selection by 1 doc px, or 10 with `Shift`. |
| `[` | `apply_reorder(Down)` | Swap with previous sibling (back in z-order). |
| `]` | `apply_reorder(Up)` | Swap with next sibling (forward in z-order). |
| `Escape` | `apply_escape` | One layer per press, in priority order: property-focus → locale picker → shape picker → fill-type picker → chat focus → selection. |
| `Enter` | `apply_send` | Commits property edit or sends chat. |
All struct fields and intra-module helpers are scoped `pub(in crate::widget_host)` so submodule `impl` blocks can reach them while the public surface stays minimal. Each file is under 480 lines.
## Desktop binary (`openpencil-desktop/`)
`crates/openpencil-desktop/src/main.rs` is the production desktop entry. It owns the winit `ApplicationHandler`, opens a GL window via `SharedSkiaContext::new_desktop`, and dispatches every `WindowEvent` onto `WidgetHostNative::apply_*`. Behaviour:
- DPI scale via `canvas.scale((dpi, dpi))` per frame (preceded by `reset_matrix()` so it doesn't compound)
- LOGICAL viewport sizes (physical / dpi)
- Cursor position cached on `CursorMoved`, dispatched on `MouseInput`
- `MouseScrollDelta::PixelDelta` → trackpad pan; `LineDelta` / `PinchGesture` → zoom; modifier (Cmd/Ctrl) promotes pixel-delta to zoom
- Cursor flips to `EwResize` when over a panel-resize gutter (`host.panel_resize_hover`)
- `WaitUntil(host.next_animation_deadline_ms())` pumps the caret-blink redraw
Native font path bypasses jian-skia's `textlayout` (which builds a fresh `FontCollection` per call → 605ms chrome frame): `NativeBackend` caches a Roboto Typeface + per-codepoint system fonts (resolved via `FontMgr::match_family_style_character`, cached per `i32`) so multi-script chrome (한국어 / हिन्दी / ไทย / Tiếng Việt) renders against the right font. `draw_text` segments each run by typeface and dispatches each segment via `Canvas::draw_str`.
Run: `cargo run -p openpencil-desktop --release`.
## Web runner (`shell-web/`)
Single `mount(canvas_id)` entry point exposed to JS. Wires DOM listeners on the canvas + window:
- mousedown/mousemove/mouseup → apply_press / apply_cursor_move / apply_release
- wheel → apply_wheel
- keydown (window) → apply_text / apply_backspace / apply_send
- IME composition (hidden textarea) → apply_ime stubs
Skia surface: `wasm32-unknown-unknown` raster (N32_PREMUL) + `put_image_data`. Fonts: embedded Roboto-Regular.ttf (~35 KB) + NotoSansCJK-Subset.ttc (~8.7 KB) loaded via `FontMgr::custom_empty().new_from_data`.
Build: needs `EMSDK` env var pointing at an emsdk install (brew emscripten won't work — needs the real emsdk layout `$EMSDK/upstream/emscripten/llvm/bin/clang++`). Once set: `tools/check-wasm-bundle.sh` runs the full bundle gate (cargo → wasm-bindgen → wasm-opt -Oz, asserts 0 env.\* imports + ≤1 MiB gzip).
Smoke: `crates/openpencil-shell-web/smoke/step-1b.html` — start `python3 -m http.server 8000` from `crates/openpencil-shell-web/` and open http://localhost:8000/smoke/step-1b.html.
## Hit-test order
Hit-test runs in REVERSE paint order so the topmost overlay always wins:
1. TopBar (sidebar toggle button) — also eats other top-bar gaps
2. AI chat panel (DragHandle starts drag; FocusInput / Send / Example / ToggleCollapse defer to apply_click)
3. Toolbar (button hits dispatch tools; gaps inside the bounding rect eat clicks)
4. apply_click → LayerPanel rows / Page rows + chat-defocus (skipped when sidebar collapsed)
5. Empty canvas press → clear `selected` (collapses RightPanel) + start pan-drag
### Coordinate invariant
Every input path that reasons about the canvas region MUST derive its rects from `canvas_region(viewport_w, viewport_h)`. Never reuse `LAYER_PANEL_WIDTH` for hit-test — paint follows `canvas_region`, which collapses to `canvas_left = 0` when `Document.ui.sidebar_open == false`. Sites that proved this rule by violating it: `over_canvas`, `apply_wheel` cursor offset, toolbar hit rect in `apply_press` / `apply_click`. Web `apply_wheel` zoom anchor + `toolbar_rect()` helper follow the same rule.
## Settings modal (`Cmd+,`)
`agent_settings_panel.rs` + 4 tab modules render an 880×640 modal opened from the TopBar agent chip or `Cmd+,`. Sidebar nav: Agents / MCP / Images / System. Right pane scrolls; modal paints last (over dim scrim) so it covers every other widget.
- **Agents** — `+ 添加服务商` and `+ 添加 Agent` actions in two empty-state sections, then 5 provider cards (Claude / Codex / OpenCode / GitHub Copilot / Gemini) with real brand logos from `widgets/brand_icons.rs`. Hovering a connected card swaps the green `✓ Connected` row for a red `断开连接` button; both lifecycle actions toggle `agent_settings.connected[i]`.
- **MCP** — server status card with port input + Start/Stop button, then a 2×3 grid of CLI integration toggles (Claude Code / Codex / Gemini / OpenCode / Kiro / GitHub Copilot). Port input is editable (see "Settings input editing" below).
- **Images** — Image Search Ready/Not-configured indicator + collapsible Advanced section (Openverse OAuth Client ID / Secret + Register link + Test button), then Image Generation section with `+ Add` empty state.
- **System** — read-only Auto-update status card (no updater backend wired yet — a togglable switch would lie to the user; the row paints as informational text).
`agent_settings_i18n.rs` carries a hand-maintained EN/ZH key table (~50 keys, `settings.tab.*` / `settings.agents.*` / `settings.mcp.*` / `settings.images.*` / `settings.system.*` / `settings.provider.*`). The repo's main `op-i18n/src/i18n/{en,zh_cn,…}.rs` tables stay untouched (they're auto-generated from `apps/web/src/i18n/locales/*.ts`); when those TS tables grow `settings.*` keys, this hand-table collapses into per-locale `lookup` calls.
### Settings input editing
`SettingsFocus { McpPort }` is to settings inputs what `PropertyFocus` is to property-panel inputs. Click on the port field → `AgentSettingsHit::FocusMcpPort``agent_settings.focus = Some(McpPort)` + `EditorUiState.settings_input: TextInputState` seeded from current port. `apply_text` / `apply_backspace` / caret movement / select-all route through the shared text-input state FIRST (swallowing every keystroke so non-digit chars don't leak into chat / rename / text-edit). `apply_send` commits, parsing u16 and clamping ≥1024. `apply_escape` clears focus and the shared input. Close / Outside / SelectTab / re-Focus all commit any pending draft first so a typed value isn't silently lost.
Mirrored on native (`widget_host/property_dispatch.rs::commit_settings_focus_if_any`) and web (`widget_host/keyboard.rs::commit_settings_focus`).
## Pen tool
`document/pen.rs` builds a `NodeKind::Path` with `points: Vec<Point2D>` (doc coords). State on `Document.ui`:
- `pen_in_progress: Option<NodeId>` — the path being authored (None when idle).
- `pen_cursor_doc: Option<Point2D>` — last cursor doc coord, drives the rubber-band preview from the last anchor to the cursor while authoring.
- `pending_pen_history: Option<DocumentSnapshot>` — snapshot captured BEFORE `start_pen_path` mutates the tree. Pushed onto the undo stack only when the path commits with ≥ 2 anchors; a lone-anchor (invisible) path is stripped without polluting history.
Press while `tool == Pen` calls `start_pen_path` or `add_pen_point` depending on `pen_in_progress`. Enter / Escape / tool change → `finish_pen_path`. Canvas paints the path through `node.points` as a polyline, plus a dashed preview line from `points.last()` to `pen_cursor_doc` while authoring.
## Color picker (HSV)
`document/color_picker.rs` + `widgets/color_picker.rs`. State: `ColorPickerState { target (Fill|Stroke), hue, sat, val, drag (None|SvBox|HueSlider), anchor_y }`. Open via `Cmd-Shift-C` or fill/stroke swatch click. **HSV stays anchored across the RGB-rounding cycle** — dragging the hue slider rewrites only `hue` and reconstructs RGB from the cached `(hue, sat, val)`, so the saturation+value crosshair doesn't visibly snap as round-trip rounding pulls a slightly different RGB out of the same HSV.
Hit-test order on the picker: hex input row → SvBox → HueSlider → close X. Outside-click closes silently. `apply_cursor_move` reads `state.drag` to feed live SvBox / HueSlider updates.
## Layer panel right-click + drag-into-container
`widgets/layer_context_menu.rs` paints a 200×N overlay on right-click of a layer row or page tab. Rows are gated on `LayerContextTarget`:
- **Layer**: Rename / Duplicate / Delete / Group / Ungroup / Lock / Hide (most rows route to dedicated `Document::*_selected` ops; Group/Ungroup live in `document/grouping.rs`).
- **Page**: Rename / Duplicate / Delete (`document/page_mutators.rs`).
Cursor-move feeds `hovered_row` so the menu highlights the row under the cursor. Outside-click closes silently. The menu paints AFTER the layer panel and BEFORE the settings modal so it sits below modals but above everything else.
Layer-panel drag now supports **cross-parent reparenting**: a drag whose drop-target falls inside a container row (Frame / Group with `children`) reparents the dragged node under that container instead of just reordering siblings. The walker in `widgets/layer_panel_walkers.rs` returns a `DropTarget { parent, before, into_container }` triple so the commit step can call `move_into_container` vs `reorder_in_place`.
## Performance gotchas
- Native chrome paint: ~30 text draws × jian-skia textlayout's per-call `FontCollection::new()` = ~600ms/frame. Fix is the cached typeface path described above. Don't add new draw_text calls without cache awareness.
- skia canvas matrix is stateful across `with_frame``canvas.reset_matrix()` before applying DPI scale each frame, otherwise scale compounds.
- jian-skia's `DrawOp::Rect` / `DrawOp::Text` go through its image-cached path. `stroke_line` / `fill_round_rect` / `stroke_round_rect` / `stroke_svg_path` / `fill_svg_path` bypass jian and call skia canvas directly (necessary because jian doesn't have those DrawOp variants).
### Hot-path optimizations (v0.8.0)
- **History via VecDeque** (`document/mutators.rs`) — `pop_front` + `push_back` capped at 100 entries; the old `Vec::remove(0)` was O(n) on every commit past the cap.
- **`Document::t` returns `&'static str`** — every locale value is a string literal, so chrome paint stores `&'static str` instead of cloning a `String` per frame. `PropertyLabels` is a `Copy` struct of static slices; widget builders propagate the `'static` lifetime so no per-paint allocations happen for labels.
- **Viewport culling** (`widgets/canvas_viewport.rs`) — `paint_node` takes a `cull: Rect` (canvas region + 64 px stroke/handle margin). Leaf nodes outside the cull skip paint entirely; containers always recurse so off-screen-parent / on-screen-child still renders.
- **Redraw scheduler** (`openpencil-desktop/src/main.rs`) — `request_redraw(dirty: bool)` + a `prepare_redraw` step that skips paint when only a tracked redraw fired and no visible state changed (kills the first-click chip flicker because macOS GL swap chain didn't perfectly hide `canvas.clear(BLACK)` between same-output frames).
- **Cursor-move coalescing** — `apply_cursor_move` cached as `pending_cursor_move`, drained on `RedrawRequested` AND right before `apply_press` / `apply_release` / `apply_right_press` (so the final drag-end frame isn't dropped). Without the press-time drain a fast drag-release could fire press before the queued cursor-move and the release saw stale hover state.
- **Font cache prewarm** (`op-host-native/src/backend/skia.rs`) — `NativeBackend::new` walks `PREWARM_CJK_CODEPOINTS` (~50 chars covering every CJK glyph used in the chrome + settings modal) through `FontMgr::match_family_style_character` at startup. Without this the first cross-tab paint stutters because each unseen CJK char triggers a synchronous system font scan.
## Native widget_host layout (expanded)
The shell-native `widget_host/` directory now houses 8 sibling submodules under the slim spine:
| File | Purpose |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `widget_host/frame_backend.rs` | `NativeFrameBackend` (`RenderBackend` impl over `NativeBackend` + `&Canvas`) |
| `widget_host/helpers.rs` | `parse_hex_color` / `color_to_hex` / `rect_contains` / `resize_bounds` + the inset / gutter / width constants |
| `widget_host/geometry.rs` | Canvas region / panel-resize hover / cursor hint / picker rect math + `update_agent_settings_hover` (hover-card tracking for the agent cards) |
| `widget_host/input.rs` | `apply_*` (non-press input handlers) including the settings-focus keyboard early-return |
| `widget_host/press.rs` | `apply_press` + `create_node_for_active_tool` (largest single method; routes through 10+ hit-test layers including the settings modal + color picker + layer context) |
| `widget_host/paint.rs` | `WidgetHostNative::paint` — full editor-UI composition pass |
| `widget_host/property_dispatch.rs` | Property-panel action dispatch + `commit_property_focus_if_any` + `commit_settings_focus_if_any` |
| `widget_host/shortcuts.rs` | Keyboard shortcut helpers (Cmd-Shift-C color picker, Cmd-Shift-G ungroup, etc.) |
## Canonical `.op` / `.pen` loader (v0.8.0+)
`openpencil-desktop/src/pen_doc_adapter.rs` bridges the canonical `jian_ops_schema::PenDocument` into the desktop's private `DocPayload` so files saved by the TS editor, Jian apps, or any other tool emitting the canonical schema load through the shared parser. Adapter responsibilities:
1. **Variant routing** — all 12 `PenNode` variants (frame / group / rectangle / ellipse / line / polygon / path / text / text_input / image / icon_font / ref) become `NodePayload`. Image / TextInput / IconFont degrade where their renderer isn't wired yet (image → grey placeholder rect; text_input → text node with seeded value / placeholder; icon_font → `NodeKind::Other("icon_font")` with `iconFontName` stashed in `text`).
2. **Layout via `jian_core::layout::LayoutEngine`** — taffy-backed flex solver, with the canonical `clipContent` / `justifyContent` / `alignItems` semantics. Each page-root gets its own engine pass; harvested rects are offset by `(base.x, base.y)` so multi-design files (e.g. `pencil-demo.op`'s 14 side-by-side mock-ups) spread across the infinite canvas instead of overlapping at origin.
3. **Text measurement via `jian_skia::SkiaMeasure`** — real skia paragraph shaper plugged in with `LayoutEngine::with_backend(Rc::new(SkiaMeasure::new()))`. Replaces the default `EstimateBackend` (~10% character-count heuristic) so `fit_content` text frames size against the same glyph advances `draw_text` paints with.
4. **Version tolerance**`persistence::load_canonical` retries with `version` rewritten to `"1.0"` when the canonical schema rejects on a legacy major (e.g. pencil-demo.op's `version: "2.8"`), so older TS files still load best-effort.
`pen_doc_path_bounds.rs` ports `pen-core/src/path-anchors.ts::getPathBoundsFromAnchors` — cubic Bezier derivative roots + extrema sweep — so authored paths with handles/curves scale into their `width` / `height` box the same way the canonical renderer paints.
## Font weight + text wrapping
- **Numeric-string `fontWeight`** — `.op` files emit weights as JSON strings (`"700"`, `"600"`, `"normal"`). Both `vendor/jian/.../layout/mod.rs::resolve_weight` and `pen_doc_adapter::resolve_font_weight` parse numeric strings first, then fall back to lucide-style named weights (`bold`/`semibold`/`medium`/`light`/`thin`/`normal`/`regular`/`extralight`/`extrabold`/`black`/`heavy`/`demibold`/`ultralight`/`ultrabold`/`hairline`).
- **`Node.text_wrap: bool`** — set only when the schema authored `textGrowth: fixed-width` (or `fixed-width-and-height`). Canvas paint wraps text only when this flag is true; otherwise paints single-line at the authored width so font-fallback overshoot doesn't break lines the TS app shows on one line. `\n` characters split into multiple lines either way.
- **`wrap_text` (canvas_viewport_overlay.rs)** — greedy CJK-aware line breaker mirroring `pen-renderer/paint-utils.ts::wrapLine`. Per-character breaks for CJK codepoints (CJK Unified + Extension A, Hiragana/Katakana, Hangul, CJK symbols, full-width), word breaks for Latin runs, blank-line preservation on empty `\n` segments. Takes `weight: u16` so measurement matches the weighted typeface paint will use.
- **`RenderBackend::measure_text_weighted(text, font_size, weight)`** — every backend (`NativeBackend`, `WebBackend`, `NativeFrameBackend`) overrides this. Native keys its per-codepoint typeface cache on `(codepoint, weight)` and queries FontMgr with `FontStyle::new(Weight, Width::Normal, Slant::Upright)`. Web ships a single-weight bundle so `measure_text_weighted` forwards to `measure_text`. The trait default forwards to `measure_text` for the heuristic fallback.
- **Synthetic bold** — both native and web `draw_text` set `PaintStyle::StrokeAndFill` + `stroke_width = font_size * 0.06` for weights ≥ 600 so the bundled single-weight Roboto-Regular paints heavy when the file asks for bold.
## Icon coverage
`icons.rs` + `icons_data.rs` (sibling — keeps `icons.rs` under cap as the catalogue grows) cover ~75 lucide variants spanning chrome glyphs (Plus / Minus / Search / Settings / etc.) plus first-party `iconFontName` strings from `packages/pen-core/src/element-builders/` (`calendar`, `clock`, `map-pin`, `more-vertical`, `chevron-left`, `trending-up`/`-down`, `compass`, `refresh-cw`, `layout-dashboard`, `users`, `package`, `zap`, `sliders-horizontal`, `activity`, `loader`, `focus`, `chart-line`, `settings-2`, `arrow-right`, `check-circle`, `alert-triangle`, `alert-octagon`, `sticky-note`, `bar-chart-2`, `bold`/`italic`/`underline`/`strikethrough`, `shopping-cart`/`-bag`, `send`, `message-circle`, `rocket`, `menu`, `credit-card`, `x-circle`, `mail`, `smartphone`, `chrome`, `apple`, `user`). All d-strings copied verbatim from `node_modules/.bun/lucide-react@0.545.0/dist/esm/icons/*.js`.
`Icon::from_name(&str) -> Option<Icon>` resolves kebab-case glyph names plus common aliases (`back` / `forward` / `cart` / `bag` / `hamburger` / `card` / `cancel` / `house` / `chart-bar` / `like` / `team` / etc.). The unknown-name fallback strokes the canonical `FALLBACK_ICON_D` (`M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0`) — a small centred dot, matching `pen-renderer/node-renderer.ts::FALLBACK_ICON_D` — so unsupported icons read as "unknown glyph" instead of a solid block.
`paint_icon_font_node(backend, name, rect, fill)` in `icons.rs` is the canvas entry point: scales the 24×24 viewBox to `min(w, h)`, centres it, strokes at `(size / 24) * 2` widths to match lucide's 2 px reference stroke.
## Hover state across menus + panels
Every dropdown / sidebar / row list paints a per-row tint when the cursor is over it. State lives on `Document.ui` (or the appropriate sub-struct) and is updated by the host's `apply_cursor_move`:
| Surface | State | Tint |
| ---------------------------------- | ----------------------------------------------------- | ------------------------ |
| File menu (`file_menu.rs`) | `Document.ui.file_menu_hover: Option<FileMenuChoice>` | `theme.muted` row wash |
| Locale picker (`locale_picker.rs`) | `Document.ui.locale_picker_hover: Option<Locale>` | `theme.muted` |
| Shape picker (`shape_picker.rs`) | `Document.ui.shape_picker_hover: Option<ShapeChoice>` | `theme.muted` |
| Layer panel rows | `Document.ui.hovered_layer_id` / `hovered_page_index` | `theme.muted` row wash |
| AgentSettings nav (sidebar tabs) | `agent_settings.hover_nav: Option<AgentSettingsTab>` | `theme.accent` |
| AgentSettings provider cards | `agent_settings.hover_provider: usize` | `theme.accent` card wash |
| Layer context menu | `layer_context_menu.hovered_row: Option<u8>` | row highlight |
Every host close path clears its respective hover state so reopening starts un-hovered.
## File-menu polish
- Action labels strip the trailing `…` (Open file / Save As / Export image — pure label, no Mac-convention ellipsis).
- Recent file rows truncate long file names with a CJK-aware `truncate_to_width` helper that reserves space for the age column on the right.
- Compact row metrics (`ROW_HEIGHT = 30`, `HEADER_HEIGHT = 22`, `PAD_Y = 6`, `MENU_WIDTH = 300`) — the menu reads tighter than the default 36/28/8/320.
## Open / Save / Export error dialogs
`persistence.rs::show_error_dialog` pops a native `rfd::MessageDialog` on every failed load / save / export with a bilingual (EN / ZH per `Document.ui.locale`) title + path + detail body. `OpenRecent` failures additionally prune the stale entry from `Document.ui.recent_files`.
## MCP server (shell-core)
`crates/openpencil-shell-core/src/mcp.rs` (+ siblings under `mcp/`) carries the MCP wire layer that mirrors the TS `packages/pen-mcp` server. Serde-free hand-rolled JSON-RPC parser keeps the wasm32 bundle small.
### Tool catalog
**77 first-party tools** registered today (v0.8.0+). The
authoritative list is `openpencil-desktop/src/mcp_serve.rs`
`TOOL_SCHEMAS` (JSON inputSchemas) plus the `rebuild_registry`
`r.register(...)` calls, guarded by an exact-count assertion test
(`tools_list_response_includes_all_seventy_seven_tools`).
By category:
- **Read (18)** — `get_document_info` / `get_selection` / `get_node`
/ `get_node_children` / `get_node_parent` / `list_pages` /
`list_variables` / `get_active_theme` / `list_components` /
`get_component` / `snapshot_layout` / `get_canvas_bounds` /
`find_node_by_name` / `count_nodes` / `list_node_kinds` /
`get_history_depth` / `get_viewport` / `get_selection_set`.
Each snapshots `Document` state at registration.
- **Node write (insert/update/delete/move/copy/replace)** —
`mcp/write_tools.rs`.
- **Per-node attribute writers** — `set_node_rotation` / `_text` /
`_corner_radius` / `_font_size` / `_font_weight` / `_stroke_hex` /
`_stroke_width` / `_fill_hex` / `_name` (`mcp/node_attr_tools.rs`).
- **Selection / clipboard / canvas ops** — `set_selection` /
`set_selection_set` / `toggle_node_selection` / `clear_selection`
/ `duplicate` / `delete` / `nudge` / `group` / `ungroup` /
`reorder` / `align_selected` / `copy_selected` / `cut_selected`
/ `paste_clipboard` / `set_active_tool` / `set_viewport` /
`set_node_hidden|locked|collapsed` / `undo` / `redo`
(`mcp/selected_ops_tools.rs` + `mcp/component_tools.rs`).
- **Pages + components** — `add_page` / `rename_page` /
`delete_page` / `duplicate_page` / `reorder_page` /
`set_active_page` / `instantiate_component` / `create_component`
/ `delete_component` / `rename_component` (`mcp/component_tools.rs`).
- **Variables + themes** — `set_variable_color|number|string|boolean`
/ `create_variable` / `delete_variable` / `rename_variable` /
`set_active_axis_value` / `cycle_active_axis_value`
(`mcp/scalar_vars.rs` + `mcp/component_tools.rs`).
- **Batch design** — `batch_design` / `design_skeleton` /
`design_content` / `design_refine` (`mcp/batch_design.rs`).
Read tools snapshot `Document` state at registration time. Write tools stay `&self`: they validate args and return `ToolOutcome::OkWithCommand(result, command)` for the host to apply via `Document::apply_mcp_command(command)`. The apply path follows pre-validate-then-mutate discipline (id space, target existence, geometry, hex, container-children consent) so a bad arg never leaves the document half-mutated.
`McpCommand::ReplaceNode.drop_children: bool` is a destructive-swap guard — replacing a container without explicit `drop_children=true` is refused at apply time so a Frame / Group can't silently lose its subtree.
### Wire-format hardening
`mcp/parser.rs::parse_tool_call` accepts both the real MCP `tools/call` envelope and the legacy direct-method shape. Multiple stop-gates baked in:
- **Structured args reject the parse.** `parse_flat_object_body` returns `None` on `{` or `[` for any value; `parse_tool_call` propagates that through a tri-state (`ParamsResult::Missing` / `Body` / `Malformed`). No scalar tool ever sees an object or array as a string-typed arg — earlier sentinel approaches were dropped because a literal `{...}` could collide with a real variable name.
- **`arguments` field is a top-level walker.** `arguments_field` iterates top-level key/value pairs in the params body so a nested `meta.arguments` can't shadow the real top-level field, and `"name":"arguments"` doesn't false-positive.
- **No client hangs on parse failure.** `run_stdio_with_applier` recovers the JSON-RPC `id` via `parser::extract_request_id` and writes a typed `ToolErrorCode::InvalidArgument` response so the client correlates + fails fast. Id-less lines drop silently — nothing to correlate against.
- **Read-only path refuses write tools.** `run_stdio` (no applier) demotes any `OkWithCommand` response to `ToolErrorCode::Internal`; clients can't see a "wrote: true" for an unapplied mutation.
### File layout
```
mcp.rs Spine: types + ToolRegistry + run_stdio*
mcp/json_serializer.rs JSON-RPC wire serializer (response_to_json + helpers)
mcp/parser.rs Wire parse — tri-state arguments_field, top-level walker
mcp/tools.rs Core read tools
mcp/extra_read_tools.rs get_node_children (carved off tools.rs at the 800-cap)
mcp/write_tools.rs Core node write tools (insert/update/delete/move/copy/replace)
mcp/node_attr_tools.rs Per-node attribute writers (rotation/text/font/stroke/fill/name)
mcp/selected_ops_tools.rs Selection ops (dup/delete/nudge/group/align/clipboard) + tests
mcp/component_tools.rs Components + pages + selection/viewport/flag/tool/undo tools
mcp/batch_design.rs BatchDesign tool + hand-rolled nodes_json parser
mcp/scalar_vars.rs Scalar + Color variables + create/delete/rename_variable
mcp/*_tests.rs Per-module sibling test files (800-line cap convention)
mcp_tests.rs (in crate root) Cross-cutting: stdio dispatch, parser invariants
```
### Host wiring
`openpencil-desktop --mcp <path>` (`crates/openpencil-desktop/src/mcp_serve.rs`) runs a JSON-RPC stdio MCP server backed by the .op file at `<path>`. External CLIs (Claude Code / Codex / Gemini / Copilot) spawn the binary in this mode to drive the Rust editor the same way they drive TS pen-mcp.
- Handshake: `initialize` returns protocol version + capabilities + serverInfo; `tools/list` enumerates all 21 tools with JSON inputSchemas; `notifications/initialized` + `ping` handled inline.
- Per-call lifecycle: re-build the `ToolRegistry` against the live document (so read-tool snapshots reflect prior writes) → dispatch through `run_stdio_with_applier` → applier closure mutates the doc + saves to disk on each successful write.
- Top-level method / id sniffing uses the same key-walker discipline as the wire parser so nested keys can't shadow the real top-level fields.
### Pending
- A real JSON Node parser would unlock `replace_node`'s subtree path + grow batch_design / design_skeleton beyond leaf-only.
- Per-phase apply semantics for the design\_\* workflow (e.g. design_refine emitting UpdateNode batches against existing nodes instead of fresh inserts).
- HttpServer / streamable-http MCP transport (lifecycle scaffold exists in `chat_http_server.rs`; wire protocol unverified).
## AI chat (real provider integration)
The floating chat panel (`widgets/ai_chat_panel.rs`) is wired to
real CLI agents, not a stub:
- `ChatState::begin_send` (shell-core `document/chat.rs`) — the
native send path: pushes the user message + an empty assistant
bubble, raises `chat.pending_send`. The web shell (with the
`codegen` feature — the production bundle) drains it into
`web_chat.rs` and streams the turn through the daemon's
`/api/ai/stream`; a transport-less build reports an honest
per-send error (the old `send()` echo stub is retired from the
web send path).
- `ChatProvider` (shell-core `chat_provider.rs`) is the
transport-free trait; real impls live desktop-side:
`chat_runtime.rs` (`BuiltInProvider`, agent-rs), `chat_claude.rs`
(`ClaudeCodeProvider`, `anthropic-agent-sdk`), `chat_subprocess.rs`
(`SubprocessProvider` for Claude/Gemini/Copilot), `chat_copilot.rs`,
`chat_http_server.rs` (Codex/OpenCode — `for_cli` returns `None`,
protocol unverified upstream).
- `chat_session.rs``ChatSession` runs a turn on a worker thread
(`ChatProvider::send` is a blocking iterator). `launch_if_pending`
drains `pending_send``provider_for_agent(chat_selected_agent)`;
`pump` streams deltas into the transcript each frame. The winit
loop wakes ~30 fps while a turn runs.
- Model chip — the chat panel's bottom-left chip shows the selected
agent (`AgentProvider::name`); clicking it cycles
`ui.chat_selected_agent` through `agent_settings.connected` via
`Document::cycle_chat_agent` (`AIChatHit::CycleModel`).
- Codex / OpenCode have no `ChatProvider` bridge yet — selecting
them writes an explicit `error: … not wired yet` into the
transcript rather than silently rerouting to another agent.