Codex review of Task 1.2 found four dangling references to the deleted
stub crates (pen-types/core/engine/codegen/figma + openpencil-app):
- package.json: drop the five -p <crate> args from cargo:wasm-check
- rust-release.yml: restore the build job, re-pointed at the real
openpencil-desktop crate so release-draft has artifacts to publish
- check-jian-boundaries.sh: 4 -> 3 invariants in the success message
- README.md: remove the deleted crate rows from the crate-list table
Replace the in-workspace copilot-sdk fork with the official
`github-copilot-sdk` crate (crates.io 0.1). Rewrite chat_copilot.rs
against its API: Client::start + a streaming SessionConfig whose
SessionHandler forwards `assistant.message_delta` / `session.error`
events into the ChatProvider channel, one client+session per turn.
Delete the crates/copilot-sdk fork directory. Build + 72 desktop
tests pass on the Rust 1.94 toolchain.
The official github-copilot-sdk requires rust-version 1.94 +
edition 2024; the workspace was pinned to 1.85. Bump the toolchain
so the SDK can be adopted for the Copilot connection. Verified:
full native build, wasm32 web check, and shell-core tests all pass
on 1.94.1 with no new errors.
The property panel's 效果 header drew a "+" icon that did nothing.
Add a PropertyPanelAction::AddEffect, emit its hit rect over the
icon in the layout walker (no paint/layout change — the icon was
already painted), and dispatch it in both hosts to
Document::add_drop_shadow_to_selected. Clicking "+" now appends a
drop shadow that renders + persists.
Append a default drop-shadow effect (CSS-style 0 4px 8px
rgba(0,0,0,0.25)) to the selected node — the building block the
property panel's 效果 "+" action will call. Guarded on a real,
editable selection like the sibling set_selected_* mutators.
Mirror the native fill_drop_shadow override on the wasm web
backend with skia MaskFilter::blur so drop-shadow effects paint as
a real soft shadow on both native and web — keeping the two
shells visually consistent.
The fill_drop_shadow RenderBackend primitive only had the default
un-blurred approximation. Override it on the native backend with
skia's MaskFilter::blur (CSS blur-radius → sigma = radius/2) so
drop-shadow effects paint as a real soft shadow on desktop.
The Copilot / Codex response parsers ran serde on the whole stdout
line, so a `Content-Length:` header prefix or a glued next-frame
header suffix would fail the parse. Add brace-counted (string- and
escape-aware) JSON-object extraction so discovery works whether the
CLI replies newline-delimited or Content-Length-framed.
The Copilot discovery wrote newline-delimited requests to
`copilot --stdio`; the official copilot-sdk frames requests with
`Content-Length` headers. The CLI happens to accept both, but
relying on that leniency is fragile. Write proper LSP-style
Content-Length frames so we speak the protocol the SDK defines.
Verified live against the installed `copilot` CLI.
Copilot discovery now drives `copilot --stdio` over JSON-RPC
(connect → models.list) — the same wire protocol (version 3) the
official github-copilot-sdk uses. Speaking the protocol directly
rather than linking the SDK crate keeps the workspace on Rust 1.85
(the SDK crate requires 1.94). Documented model names stay as the
fallback when the CLI is installed but the query doesn't answer.
Codex discovery now drives `codex app-server` over JSON-RPC stdio
(initialize → initialized → model/list), the official App Server
API — more accurate and stable than reading models_cache.json,
which may be stale or absent. The cache stays as a fallback, then
a minimal placeholder when codex is installed but neither source
answers. A reader thread + deadline keeps a hung server from
blocking discovery.
Replace the chat panel's agent-cycling chip with a real model
picker: the chip shows the selected model's provider brand logo +
display name, and clicking it opens an upward dropdown listing
chat.available_models grouped by provider with a selected check
(mirrors the TS ai-chat-model-selector). Selecting a model also
re-syncs chat_selected_agent to its provider so the chat transport
targets the matching CLI. Outside clicks dismiss the picker.
The chat panel never had a model list — shell-core is transport-free
and cannot query a CLI. Add desktop-side cross-platform discovery:
Codex's models_cache.json (real, file-based), `opencode models`
(real, subprocess), and the documented CLI model names for Claude /
Gemini / Copilot which expose no listing interface — each gated on
the CLI actually being installed (PATH walk with platform exe
extensions). Discovery runs on a worker thread and drains into
chat.available_models. shell-core keeps only the ModelEntry type.
Move the per-frame paint pass to frame.rs to keep main.rs under cap.
Node.effects already round-trips through persistence and canonical
import, but the canvas never rendered it. Add a fill_drop_shadow
RenderBackend primitive (default impl approximates with a translucent
fill; native + web will override with skia MaskFilter blur) and paint
each DropShadow behind Frame / Rect / Ellipse fills, offset + blur
scaled by viewport zoom.
Canonical .op shadow colours use functional rgba()/rgb() notation, which
the hex-only parser rejected — valid shadows were imported as opaque
black, then dropped entirely after the prior fix. Add parse_css_color
covering both hex and functional forms. Move the effect-conversion
helpers into persistence_effects.rs so pen_doc_adapter.rs stays under
the 800-line cap.
`shadows_from_canonical` fell back to `[0,0,0,1.0]` (opaque black)
when `parse_hex` rejected a shadow colour — so an `rgba()`,
named, or short-hex colour imported as a wrong solid-black
shadow (codex stop-gate). Switch the fallback to `filter_map`
`None`: an unparseable colour drops that shadow entirely, which
is honest (no shadow) rather than misleading (wrong shadow).
Matches `first_solid_color`'s existing `parse_hex(...)?` discipline.
`pen_doc_adapter::node_to_payload` dropped every `PenEffect` —
a `.op` authored with shadows lost them on import (codex stop-gate;
the inverse of the save-path effects round-trip just added).
`shadows_from_canonical` reads each variant's effects (Frame /
Group / Rectangle carry them on `container`, leaf shapes directly;
IconFont / Ref have none) and maps `PenEffect::Shadow` →
`ShadowPayload`. Blur / background-blur are skipped — the shell's
`Effect` model is drop-shadow-only today.
First slice of the Effects gap — the property panel's 效果 section
was a header-only stub with no data behind it.
- `Effect` / `DropShadow` types in `document.rs`; `Node.effects:
Vec<Effect>` (offset_x/y + blur + color, doc-px). Both `Node`
builders + `deep_clone_with_new_ids` carry it.
- `persistence_effects.rs` — `ShadowPayload` + `effects_to_payload`
/ `effects_from_payload`; `NodePayload.effects` is `#[serde(default)]`
so legacy `.op` files still load. Carved into its own module so
`persistence.rs` stays under the 800-line cap.
- Round-trip tested through the real serde JSON path (2 tests).
858 workspace tests pass. Still pending (own slices): canvas
drop-shadow paint (needs a blur primitive on `RenderBackend`),
the property-panel 效果 editing rows, and a `set_node_shadow`
MCP tool. Shadows currently round-trip through save/load but do
not yet render.
- crates/CLAUDE.md MCP catalog was stale at 21 tools; replace the
outdated table with the current 77-tool categorized summary and
point at mcp_serve.rs TOOL_SCHEMAS as the source of truth.
- Refresh the mcp/ file-layout list (node_attr_tools,
selected_ops_tools, extra_read_tools, json_serializer, …).
- New "AI chat (real provider integration)" section documenting
begin_send / ChatProvider / chat_session / the model chip and
the Codex/OpenCode honest-error behavior.
- chat_session: gate the test-only `finished()` accessor behind
`#[cfg(test)]` to drop a dead-code warning.
When a send for an unwired agent (Codex / OpenCode) landed while a
previous turn was still streaming, `launch_if_pending` wrote the
error bubble but left `current` pointing at the old session — so
the next `pump` streamed the prior agent's deltas straight into
the fresh error message. Clear `current` before writing the error.
`provider_for_agent` silently substituted Claude Code when the
user picked Codex or OpenCode — so the transcript looked like the
chosen CLI answered when it hadn't. Codex stop-gate.
It now returns `Option`: `None` for Codex / OpenCode (HTTP-server
transport not yet bridged). `launch_if_pending` writes an explicit
`error: … not wired yet` into the assistant bubble and starts no
session, so the user knows their pick didn't run.
The chat panel's bottom-toolbar chip was a hardcoded "Default"
label. It now shows the selected CLI agent and cycles through the
connected ones on click.
- `UiState.chat_selected_agent` — index into `AgentProvider::ALL`
(Claude Code / Codex CLI / OpenCode / GitHub Copilot / Gemini).
- `Document::cycle_chat_agent` — advances to the next *connected*
agent (`agent_settings.connected`); walks all 5 when none are
connected so the user can pre-pick before connecting.
- `AIChatHit::CycleModel` — the chip's left 150 px of the input
toolbar; `apply_click` routes it to `cycle_chat_agent`.
- The chip label now renders `AgentProvider::name()` instead of
the static "Default".
- `chat_session::provider_for_agent` routes the selected agent to
its `ChatProvider`: Claude Code → SDK adapter, Copilot / Gemini
→ subprocess transport. Codex / OpenCode (HTTP-server CLIs whose
bridge isn't wired yet) fall back to Claude Code.
856 workspace tests pass (+2: cycle walks connected / wraps,
cycle with nothing connected walks all five).
The chat send path was hardwired to a stub (`ChatState::send` →
"(stub) Got it"). The real providers existed but main.rs never
called them.
- `ChatState::begin_send()` — native-host send: pushes the user
message + an empty assistant bubble, raises `pending_send`. The
web shell keeps the offline `send()` stub.
- `chat_session.rs` — `ChatSession` runs the turn on a background
thread (`ChatProvider::send` is a blocking iterator; draining it
on the UI thread would freeze the window). `poll()` is
non-blocking; `launch_if_pending` / `pump` are the desktop glue.
- main.rs — Enter key + Send-button click drain `pending_send`
into a `ChatSession` against `ClaudeCodeProvider`; the winit
loop pumps deltas into the transcript, waking ~30 fps mid-turn.
A missing / unauthenticated `claude` CLI now surfaces as an
`error: claude query: …` message instead of a fake stub reply.
854 workspace tests pass (+4: chat_session stream/error,
begin_send push/empty).
Append a fresh empty page + switch the active page to it. No
args. Mirrors TS `addPage()`. Applier returns false on
id-space exhaustion at `max_node_id() + 1`.
Wire shape:
args: {}
result: { "wrote": "true" }
command: `McpCommand::AddPage`
Companion to set_active_page (commit 1af880c2): LLMs can now
create + navigate pages without touching the UI. Future patches
can add rename_page / delete_page / duplicate_page to complete
the page CRUD (Document already has the mutators).
Desktop --mcp registry + tools/list schema + exact-count test
updated for 29 tools.
28th tool in the catalog. set_active_page lets LLMs switch which
page is the active target for subsequent inserts / batch_design /
design_* commands. Required arg: `index` (0-based u32). The
applier rejects out-of-range indices.
New `Document::set_active_page(idx) -> bool` mutator in
page_mutators.rs (peer of add_page / duplicate_page / remove_page).
Clears selection on switch so stale-selection ops don't carry
across pages.
Companion refactor: write_tools.rs was at 900 lines (over the
800-line cap) after the components CRUD shipped. Split out
components/page-related tools to `mcp/component_tools.rs`
(InstantiateComponent / CreateComponent / DeleteComponent /
RenameComponent / SetActivePage). write_tools.rs back to 686.
Desktop --mcp registry + tools/list schema + exact-count test
updated for 28 tools.
27th tool in the catalog. Completes the components MCP read
surface: list_components returns the catalog (name + id pairs);
get_component drills into one entry and returns:
- name (display name)
- kind ("frame" / "group" / etc — the component's root NodeKind)
- leaf_count (sum of leaves in the prototype subtree)
LLM workflow: list_components to discover the catalog →
get_component to size each candidate → instantiate_component to
drop a clone.
Wire shape:
args: { "component_id": "<positive u64>" }
result: { name, kind, leaf_count } or ToolFailed when the id
doesn't resolve.
The snapshot factory computes leaf_count at registration time
so the tool stays `&self`. NodeKind enum mapping shared with
the rest of the MCP surface ("frame" / "group" / "rect" / etc).
Desktop --mcp registry + tools/list schema + exact-count test
updated for 27 tools.
Adds the missing update verb to the components registry CRUD.
26 tools total now. The full lifecycle on the MCP side:
- list_components (read)
- create_component (create)
- rename_component (update — name only today; the prototype
subtree stays whatever node was promoted at create time)
- instantiate_component (clone onto page)
- delete_component (delete)
Wire shape:
args: { "component_id": "<positive u64>", "name": "<string>" }
result: { "wrote": "true" }
command: `McpCommand::RenameComponent { component_id, name }`
New `ComponentLibrary::rename(id, new_name) -> bool` mutator
rejects empty / whitespace-only names so list_components never
returns a stub a user can't recognize. Apply layer also
defensively rejects unknown ids.
Desktop --mcp registry + tools/list schema + exact-count test
updated for 26 tools.
Completes the components registry MCP lifecycle:
- list_components (read)
- create_component (create from existing Frame/Group node)
- instantiate_component (drop a clone onto the active page)
- delete_component (remove from registry)
25 tools total. Live instances already on the page are NOT
affected by delete — they're independent clones; the registry
only holds the prototype that future instantiate_component
calls would have used.
Wire shape:
args: { "component_id": "<positive u64>" }
result: { "wrote": "true" }
command: `McpCommand::DeleteComponent { component_id }`
New `ComponentLibrary::remove(id) -> bool` mutator returns
whether a component was removed; apply layer propagates that
so the LLM can tell whether the id resolved.
Desktop --mcp registry + tools/list schema + exact-count test
all updated for 25 tools.
Closes the components MCP lifecycle: list_components (read),
create_component (promote a node to a component),
instantiate_component (drop a clone onto the active page). 24th
tool in the catalog.
Wire shape:
args: { "node_id": "<positive u64>", "name": "<string>" }
result: { "wrote": "true" }
command: `McpCommand::CreateComponent { node_id, name }`
New `Document::create_component_from_node` mutator (peer of
the existing `create_component_from_selected`) takes the target
id explicitly instead of reading `self.selected`, because MCP
sessions have no UI selection state. Both share the same
container-kind guard (Frame / Group only) and replace-on-
duplicate-id semantics via `ComponentLibrary::insert`.
Apply path rejects unknown ids + non-container kinds.
Variables table rejects InstantiateComponent + CreateComponent
in its Pages-level guard (defense in depth).
Desktop --mcp registry + tools/list schema + exact-count test
all updated for 24 tools.
LLM workflow:
1. list_components to see what's registered
2. create_component on a Frame / Group to register it
3. instantiate_component to drop a clone of any registered component
Closes the matching write surface to list_components. 23rd tool
in the catalog. LLM clients can now (a) list registered
components via list_components and (b) drop a clone of any of
them onto the active page via instantiate_component.
Wire shape:
args: { "component_id": "<positive u64 id from list_components>" }
result: { "wrote": "true" }
command: `McpCommand::InstantiateComponent { component_id }`
The apply path routes through the existing
`Document::instantiate_component` mutator, which deep-clones
the component's root subtree with fresh ids past
`max_node_id()`, appends it to the active page, pushes a
history snapshot, and selects the new instance root. Returns
false when the component id is unknown.
Desktop --mcp registry + tools/list schema + exact-count test
all updated; handshake test now expects 23 tools.
Components panel UI is still pending (a future patch adds a
right-rail Components section); the MCP surface is the
LLM-driven workflow alternative until then.
Surfaces the existing Document.components ComponentLibrary via
MCP so LLM clients can discover what reusable Frame / Group
subtrees the user has saved. 22nd tool in the catalog (was 21).
Wire shape:
count — total component count
components — `;`-records of `name|id`, with the standard
2-level escape on each side (matches list_variables /
list_pages wire convention).
Closes the read-only MCP surface for components. Instance
insertion via MCP (the matching write tool) requires a JSON
Node descriptor and is the natural follow-up; the data model +
`Document::instantiate_component` already exist.
Desktop --mcp registry + tools/list schema updated; test
renamed to all_twenty_two_tools with exact-count guard intact.