`widget_host/input.rs` was 882 lines, over the project's documented
800-line ceiling. Split the `impl WidgetHostNative` block into two
files along a natural boundary:
input.rs (417 lines — pointer / wheel / pan / cursor-move /
release + drag-commit helpers)
keyboard.rs (481 lines — text / backspace / delete / duplicate /
nudge / send / escape / click routing)
Both `impl WidgetHostNative` blocks share the same type, and Rust
allows multiple impl blocks for one type in the same crate so the
public method surface is preserved verbatim. No behavior changes —
every method moved verbatim with its full body + doc-comment.
Module wiring:
crates/openpencil-shell-native/src/widget_host.rs:
`mod keyboard;` registered between `input_tests` and `paint`
in the alphabetical mod list.
Imports adjusted: keyboard.rs picks up only the symbols its
methods reference (PropertyFocus, AIChatHit / AIChatPlaceholder /
LayerPanel / LayoutCx / Toolbar / Widget + TOOLBAR_WIDTH +
TOP_BAR_HEIGHT, Point2D + Rect, TOOLBAR_INSET_X/Y from helpers).
input.rs drops the now-unused imports of those symbols.
Tests: 54 desktop + 250+ shell-core + 20 shell-native all pass.
File-cap check:
input.rs 417 (was 882, well under 800)
keyboard.rs 481 (new, well under 800)
main.rs still pending — Task #55 follow-up (804 lines today, 4 over).
Codex stop-gate flagged the previous commit (9bb77b3f) still
didn't handle the real MCP wire shape. The actual JSON-RPC envelope
real clients (Claude Code, Codex, Gemini, etc.) send is:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_node",
"arguments": { "node_id": "42" }
}
}
`method` is `"tools/call"` (a constant); the tool's actual name +
arguments are nested under `params.name` + `params.arguments`. The
parser was extracting `method` as the tool name and reading args
from the top-level `params`, which means every real MCP request
would route to a non-existent tool called `"tools/call"`.
`parse_tool_call` now branches:
- **method == "tools/call"** → real MCP path. Extract `params`
body, read `name` string + `arguments` object from inside.
- **method != "tools/call"** → legacy/direct path (tests + tools/
list-style introspection). Method is the tool name; top-level
params is the args.
Three small helpers added:
- `extract_params_body(line)` returns the `"params":{...}` body
string without surrounding braces (so the MCP path can re-walk
it for nested fields). Brace-depth aware with quote/escape state
so embedded strings don't fool the matcher.
- `extract_string_field(body, key)` pulls `name` out of the params
body.
- `extract_object_body(body, key)` pulls the `arguments` nested
object's body for `parse_flat_object_body` to walk.
All helpers are pure-stdlib + serde-free (shell-core stays
wasm32-clean).
Tests (4 added → 27 total mcp; 267 total shell-core):
- parse_tool_call_real_mcp_tools_call_shape — repro of the codex
BLOCK (`{"method":"tools/call","params":{"name":"get_node",
"arguments":{"node_id":"42"}}}` → tool=get_node, node_id=42)
- parse_tool_call_mcp_shape_with_no_arguments — empty
`arguments:{}` still parses, args map empty
- parse_tool_call_mcp_shape_with_numeric_arg — number + bool args
survive as literal text (`5`, `true`)
- get_node_reachable_through_real_mcp_envelope — end-to-end
regression: parse → dispatch → Ok kind=frame, id preservation
through ToolResponse::Ok
Legacy/flat-shape tests still pass — both paths are first-class.
Codex stop-gate review caught a real BLOCK: the previous commit
(4a527430) shipped a `get_node` tool that requires a `node_id`
argument, but `parse_tool_call` had an explicit stub:
arguments: BTreeMap::new(), // "Empty arguments map — real
// implementation parses params"
So calling `get_node` via the stdio MCP server (the only wire path
shell-core ships) always errored with MissingArgument. The tool
was reachable only by direct registry-dispatch, which no real LLM
client uses.
`parse_tool_call` now extracts the JSON-RPC `"params"` object into
the `arguments` map. Two helpers carry the hand-rolled JSON walk:
- `extract_params_object(line)` finds `"params":{...}` by
string search + tracks `{`/`}` depth with quote/escape state
so embedded strings don't fool the matcher.
- `parse_flat_object_body(body)` walks key-value pairs:
- String values (`"node_id":"42"`) — quotes stripped, body
stored verbatim.
- Numbers / bools / null (`"page":1`, `"on":true`) — stored
as their literal text. Tools call `.parse::<T>()` on the
result so `1` is interchangeable with `"1"`.
- Nested objects + arrays are skipped (no tool today takes
non-scalar args; surfacing them needs serde).
Whitespace + commas between pairs are tolerated.
Shell-core stays serde-free; the parser stays ~80 lines so the
wasm32 cost is negligible.
Tests (5 added, 23 mcp tests total, 263 shell-core total):
- parse_tool_call_extracts_string_params (the codex BLOCK
repro: `{"params":{"node_id":"42"}}` → arguments has node_id)
- parse_tool_call_extracts_numeric_and_bool_params (`1`, `true`
survive as text)
- parse_tool_call_handles_missing_params (no params object →
empty args, parse succeeds)
- parse_tool_call_skips_nested_object_values (siblings remain
accessible; nested object intentionally omitted from map)
- get_node_reachable_through_stdio_path (regression test for
the original BLOCK: wire-format string → parse_tool_call →
registry.dispatch → ToolResponse::Ok kind=frame)
Grows the MCP server's tool surface from the placeholder
`get_document_info` (which only reported page count + total nodes)
to a useful triplet that LLM clients can chain for the "look-then-
modify" pattern the TS `pen-mcp` was designed around.
`crates/openpencil-shell-core/src/mcp.rs`:
- `GetSelection` + `selection_snapshot(doc)`:
Returns `selected_id` (raw u64), `kind` (frame / group / rect
/ ellipse / polygon / line / text / path / other / none /
missing), `x` / `y` / `width` / `height` from the selected
node's `aggregate_bounds`. `kind="none"` when nothing is
selected; `kind="missing"` when the selected NodeId doesn't
resolve (post-deletion race etc.).
- `ListPages` + `list_pages_snapshot(doc)`:
Returns `page_count`, `active_page_index`, and a
comma-separated `names` list. Lets the LLM pick a target
page before calling `insert_node` / `batch_design`.
- `GetNode` + `get_node_snapshot(doc)`:
Takes a `node_id` argument (decimal u64 string), returns
`kind` / `name` / `x` / `y` / `width` / `height` /
`parent_id`. O(1) lookup against a map built at snapshot
time by walking every page recursively. Argument errors are
typed: `MissingArgument` when node_id absent,
`InvalidArgument` when not a u64, `ToolFailed` when the id
isn't in the document.
The snapshot-at-registration pattern matches the existing
`GetDocumentInfo`. The MCP server is expected to re-snapshot +
re-register on document mutations; this keeps the `McpTool` trait
trivially `Send + Sync` without dragging in `Arc<RwLock<Document>>`
or other interior-mutability machinery.
Tests (7 added, all pass):
- get_selection_reports_no_selection_when_none
- get_selection_reports_selected_node_bounds_and_kind (against
the sample Frame at NodeId 10)
- list_pages_reports_count_and_names
- get_node_returns_record_for_known_id
- get_node_errors_on_unknown_id (ToolFailed)
- get_node_errors_on_missing_arg (MissingArgument)
- get_node_errors_on_non_numeric_arg (InvalidArgument)
shell-core test count: 258 (was 251). MCP module test count: 18
(was 11).
Next: `insert_node` + `batch_design` write tools — those need the
host to plumb a mutable Document handle through, which is bigger
scope than the read-only triplet here.
Closes the "P3. PropertyPanel Export section: real buttons" task that
was left preview-only by the export chain (P1 raster export + P2
ExportDialog modal). The Export section paints two pill dropdowns
(scale + format) but no clicks were wired — opening the dialog
required Cmd+Shift+P or the File menu.
`property_panel_layout.rs`:
- `VisibleSections` grew `effects: bool` + `export: bool`, both
required for the action-button walker to compute the y-position
of the Export section. `ALL` const + every construction site
updated.
- `action_button_rects_with_fill_picker` now continues its y-walk
past Fill through Stroke + Effects sections (consuming the
paint-side heights so the rect math stays aligned), then emits
one `OpenExportDialog` rect spanning the full Export row.
Clicking anywhere on the row resolves to OpenExportDialog —
splitting scale + format into separate hit zones isn't worth
it because the ExportDialog modal owns both pickers.
- Fill-section walker now explicitly consumes the head row +
body + divider gap heights it had been implicit about. Without
this any section after Fill (Stroke / Effects / Export) sat at
the wrong y in the hit-test space.
`property_panel.rs`:
- The three `VisibleSections` construction sites (action hit-test,
input hit-test, fill-picker overlay paint) now thread
`caps.effects` + `caps.export`. Mechanical sed-based patch since
the three sites are identical.
The downstream wiring was already in place — `widget_host/
property_dispatch.rs` handles `A::OpenExportDialog` by queueing
`FileAction::ExportImage` so the desktop binary's save-dialog +
real raster export kicks in. P3 was the missing piece between user
intent (click the section) and the existing pipeline.
Tests (1 added):
- `hit_test_action_export_section_returns_open_dialog` — selects
the sample Frame (NodeId 10), walks `action_button_rects_with_
fill_picker` to find the OpenExportDialog rect, clicks its
center, asserts hit_test_action returns OpenExportDialog. This
is the rect-walker contract that paint + hit-test stay in sync
even when sections after Fill are visible.
Total shell-core tests: 251 (was 250).
Codex stop-gate review flagged that `HttpServerProvider::for_cli(Codex
| OpenCode)` claimed to work but was wired to a fabricated protocol:
- OpenAI's `codex` CLI does not ship a `serve` subcommand, so the
`for_cli(Codex)` path was guaranteed to fail at spawn.
- sst/opencode does ship `opencode serve`, but its real
request/response JSON shape was not validated against my
`{ "message": ... }` + newline-delimited `text/thinking/tool_use/
done/error` defaults — those were placeholders, not specs.
Original commit (0e27998e) called this out in the commit body but
the public API still pretended `for_cli` returned a working bridge
for either CLI, which is exactly the kind of false-confidence
codex objected to.
Fix:
- `for_cli(CliName)` now returns `None` for every variant, with a
doc comment explaining that the wire protocols haven't been
wired up. Callers who've verified a server's real protocol use
`with_binary(...)` and supply explicit binary + serve args +
chat path themselves.
- Module-level docs gained an explicit "honest scope note" listing
what is and isn't verified: the lifecycle plumbing (spawn,
stderr drain, listen-line discovery via `parse_listening_line`,
streaming response → parse_line dispatch, receiver-drop kill)
is structurally correct and reusable; the per-CLI protocol
defaults are placeholders.
- Tests updated: `for_cli_only_http_server_kinds` →
`for_cli_returns_none_pending_real_protocol_wiring` (asserts
every CliName now returns None); the trait-object + label tests
swapped to construct via `with_binary` with an arbitrary
binary, since that's the supported construction path today.
No code path was deleted — when a real `codex serve` or
`opencode serve` spec lands the work is "add a thin factory that
constructs an HttpServerProvider with the verified args + plug in
the matching response parser." The framework is ready; the
configuration is not.
Tests: 6 chat_http_server tests pass (was 7 — the redundant
codex/opencode label test was merged into the trait-object test).
Desktop total: 54 pass (was 55).
Closes the fourth chat-backend category from the project_agent_runtime
memory. Implements user direction "opencode 和 codex 我们调用 http
server, 通过 ipc 启动本地的 server 模式" — spawn the CLI as a local
HTTP server then POST chat requests to its bound port.
`crates/openpencil-desktop/src/chat_http_server.rs`:
- `HttpServerProvider::for_cli(CliName::Codex | OpenCode)` builds a
bridge that spawns `<bin> serve` and connects to the local
endpoint. Other CliName variants return `None`.
- Lifecycle:
1. Spawn child with stdin=null, stdout+stderr piped.
2. Drain stderr to /dev/null on a sibling task so the server
can't deadlock on a full pipe.
3. Block on stdout lines until one announces the bound port.
Default 10-second timeout (cold first-run can be slow). The
child is killed on timeout so we never leak a half-started
server.
4. Continue draining stdout for the remainder of the server's
life so its operational logs don't back-pressure.
5. POST `{ "message": <prompt> }` to `127.0.0.1:<port><path>`
using reqwest. Non-2xx response → Error + Done { Aborted }.
6. Stream the response bytes; parse each newline-delimited
line through `chat_subprocess::parse_line` (the generic
text / thinking / tool_use / done / error envelope).
7. On any structured `done`, terminate; on receiver-drop,
start_kill the child.
- `parse_listening_line` handles three message formats observed
in the wild: `Listening on http://host:PORT` (Codex),
`Server listening on port NNNN` (OpenCode docs), bare
`listening NNNN` (some local-server frameworks).
`extract_port` prefers `:NNNN` after the last colon (HTTP URL
form) and falls back to the last digit run, so IP octets in
`127.0.0.1:8765` don't get picked up as the port (caught by a
failing test on first iteration).
- `chat_path` is a per-CLI template (defaults to `/v1/chat`); the
settings modal can swap it when wiring up a new server whose
URL differs.
Limitations to flag for future iterations:
- The server is killed after each `send`. Real multi-turn would
want a long-lived server, which means lifting the spawn into a
Client-like singleton (analogous to chat_copilot's
ClientSession). Today's per-send spawn pays the cold-start
twice per turn but keeps the bridge stateless.
- The request body shape (`{ "message": ... }`) is a guess. Each
server's actual API needs to be plumbed when we have real
Codex / OpenCode server specs to compare against.
`crates/openpencil-desktop/Cargo.toml`:
- Adds `reqwest = { version = "0.12", default-features = false,
features = ["rustls-tls", "json", "stream"] }` as a direct dep.
reqwest was already in the graph via anthropic-agent-sdk so the
cost is zero new transitive deps; declaring it directly makes
the chat_http_server module's intent explicit.
`crates/openpencil-desktop/src/chat_subprocess.rs`:
- `parse_line` lifted to `pub(crate)` so chat_http_server can
share the same wire-protocol parser. Centralizing the envelope
shape keeps the four bridges consistent — when one CLI's
protocol evolves, every bridge gets it.
`crates/openpencil-desktop/src/main.rs`:
- `mod chat_http_server;` slotted into the alphabetical mod list.
Tests (7 added, all pass):
- parse_listening_line_codex_format: `Listening on
http://127.0.0.1:8765` → 8765 (asserts the IP-octet rejection
that caught the first iteration's bug)
- parse_listening_line_opencode_format
- parse_listening_line_bare_number
- parse_listening_line_returns_none_when_absent
- for_cli_only_http_server_kinds: Codex + OpenCode → Some, others
→ None
- provider_constructs_as_chat_provider_trait_object: type-check
- provider_labels_match_cli_names: "Codex" / "OpenCode"
All four chat backends now wired in code:
✓ BuiltIn (agent-rs) — chat_runtime.rs
✓ ClaudeCode (anthropic-agent-sdk) — chat_claude.rs
✓ Copilot (copilot-sdk) — chat_copilot.rs
✓ Subprocess (Gemini generic + custom)— chat_subprocess.rs
✓ HttpServer (Codex + OpenCode) — chat_http_server.rs
Acp (third-party ndJSON) remains TODO — that's the fifth backend
in the architecture memo, the open extension point for CLIs we
don't ship a dedicated adapter for.
Total openpencil-desktop tests: 55 (was 48 before this commit).
Second per-CLI ChatProvider adapter. Wires
`copilot_sdk::Client + Session` (the in-workspace fork of
copilot-community-sdk/copilot-sdk-rust) into the OP chat panel.
`crates/openpencil-desktop/src/chat_copilot.rs`:
- `CopilotProvider` holds a lazy `Arc<Mutex<Option<ClientSession>>>`
that boots a single `Client` + `Session` on the first `send`.
Subsequent sends reuse the same session, so multi-turn history
is the SDK's responsibility (it's what `Session` was designed
for — much cleaner than reconstructing per-turn).
- `ensure_session` is the lazy-init: builds `Client::builder()`,
calls `client.start().await`, creates a default `SessionConfig`.
Mutex-guarded so concurrent `send` calls race onto the init
path safely.
- `send()` subscribes to the session's broadcast event channel
BEFORE calling `session.send(prompt)` so we don't miss the
first delta. Then loops `events.recv()` and dispatches each
`SessionEventData` variant via `dispatch_event`:
- `AssistantMessageDelta { delta_content }` → `TextDelta`
- `AssistantReasoningDelta { delta_content }` → `Thinking`
- `ToolExecutionStart { tool_name, arguments }` → `ToolUse`
- `AssistantTurnEnd` or `SessionIdle` → `Done { EndTurn }`
- `SessionError { message }` → `Error + Done { Aborted }`
- `Abort` → `Done { Aborted }`
- All other variants (turn-start / intent / usage /
hook-start/end / tool-progress / tool-complete / custom-
agent lifecycle / system messages) silently swallowed
today — the SDK's full event surface is ~35 variants, and
the chat widget can grow into them later without breaking
this adapter.
- `AssistantMessage` (the final batched message) is intentionally
NOT mapped because it duplicates content already streamed via
`AssistantMessageDelta`. Future work could dedup heuristically
when deltas were dropped.
- Receiver-drop short-circuit on every iteration so closing the
chat panel tears the bridge down without waiting for the next
event.
`crates/openpencil-desktop/src/main.rs`:
- `mod chat_copilot;` slotted after `chat_claude` (alphabetical).
Tests (2 added):
- provider_label_is_human_readable: returns "GitHub Copilot"
- provider_constructs_as_chat_provider_trait_object: compile-time
check that `CopilotProvider` satisfies `Send + Sync` bounds for
`Arc<dyn ChatProvider>` storage.
Live end-to-end testing requires `gh copilot` installed and logged
in (the SDK does the lifecycle handshake on `client.start()`).
The 2 tests verify wiring + type contracts; live verification is
deferred until the settings modal exposes a "Test connection"
button per provider.
Total openpencil-desktop tests: 48 (was 46).
Three of four chat backends now wired:
✓ BuiltIn (agent-rs) — chat_runtime.rs
✓ ClaudeCode (anthropic-agent-sdk) — chat_claude.rs
✓ Copilot (copilot-sdk) — chat_copilot.rs
- Generic Subprocess (Gemini) — chat_subprocess.rs
- HttpServer (Codex / OpenCode) — TODO
- Acp (third-party) — TODO
Next: HttpServer bridge for `codex serve` + `opencode serve` per
the user's "opencode 和 codex 我们调用 http server, 通过 ipc 启动本地的
server 模式".
First of the per-CLI ChatProvider adapters that replace the
hand-rolled stream-JSON parser in chat_subprocess.rs. This one wires
`anthropic_agent_sdk::query` (the in-workspace fork of
bartolli/anthropic-agent-sdk) into the OP chat-panel plumbing.
`crates/openpencil-desktop/src/chat_claude.rs`:
- `ClaudeCodeProvider` impls `ChatProvider`. Constructs trivially
via `new()` (SDK defaults) or `with_options(ClaudeAgentOptions)`
when the settings modal has user overrides (system prompt, model
pick, allowed-tools list, MCP servers, sandbox config — all 30+
SDK option fields).
- `send()` spawns the shared tokio runtime task, calls
`anthropic_agent_sdk::query(prompt, options)`, drains its async
`Stream<Item = Result<Message>>`, and dispatches each Message
through `handle_message`:
- `Message::Assistant.content` Vec<ContentBlock> is unpacked
per block: `Text { text }` → `ChatDelta::TextDelta`,
`Thinking { thinking, .. }` → `Thinking`, `ToolUse { name,
input, .. }` → `ToolUse { name, args = input.to_string() }`,
`ToolResult` swallowed (already part of conversation history
the CLI tracks).
- `Message::Result { subtype, is_error, .. }` is the turn
terminator. `is_error` → `StopReason::Aborted`; otherwise
`map_result_subtype` maps "success" → EndTurn,
"error_max_turns" → MaxTokens, error variants → Aborted,
unknown → EndTurn.
- `System` / `User` / `StreamEvent` swallowed (init / context /
partial-stream payloads the chat widget doesn't surface yet).
- Receiver-drop short-circuit: every iteration checks
`tx.is_closed()` so chat-panel teardown stops the SDK stream
promptly without waiting for the CLI to flush more output.
- Always emits a terminal `Done` — `Result` message → mapped stop
reason; stream EOF without a Result → `EndTurn` fallback.
`crates/openpencil-desktop/Cargo.toml`:
- Adds `anthropic-agent-sdk = { path = "../anthropic-agent-sdk" }`
+ `copilot-sdk = { path = "../copilot-sdk" }`. Copilot dep
declared now even though `chat_copilot.rs` lands in a follow-up,
so Cargo.lock resolves the whole graph in one pass.
`crates/openpencil-desktop/src/main.rs`:
- `mod chat_claude;` between `mod chat_runtime` and
`mod chat_subprocess` so the alphabetical mod-list rule holds.
Tests (3 added, all pass):
- `map_result_subtype_table` covers the success / error_max_turns /
error_during_execution / error / unknown table.
- `provider_label_is_human_readable` asserts the chat widget gets
"Claude Code" as the displayed label.
- `provider_constructs_as_chat_provider_trait_object` is the
compile-time type-check that `ClaudeCodeProvider` satisfies the
`Send + Sync` bounds so it can live behind `Arc<dyn ChatProvider>`
in the widget host.
End-to-end smoke testing requires an actual `claude` binary on PATH.
The 3 tests here verify the wiring + type contracts but not the live
CLI interaction; that lands when the settings modal exposes the
"connect" button + we have a real session to drive.
46 openpencil-desktop tests pass (was 43 before this commit).
Next: chat_copilot.rs over `copilot_sdk::Client + Session`, then
chat_http_server.rs for Codex / OpenCode `serve` mode per the user's
"opencode 和 codex 我们调用 http server, 通过 ipc 启动本地的 server 模式".
Per user direction "可以不放在 vendor 里面,我们移动到自己的工程,
后面就和他们分叉" — promote the two community SDKs from vendor/ to
crates/ so they become first-class OP workspace members we own and
evolve, instead of read-only vendored snapshots.
Moves:
vendor/anthropic-agent-sdk/ → crates/anthropic-agent-sdk/
vendor/copilot-sdk-rust/ → crates/copilot-sdk/
Workspace integration:
- Root `Cargo.toml` exclude list drops both vendor entries; the
existing `members = ["crates/*"]` glob auto-includes them.
- `crates/copilot-sdk/Cargo.toml`: stripped all `[[example]]`
blocks (22 of them) — the examples/ dir was already removed
during the import, and leaving the entries broke
`cargo test --workspace --no-run`.
- `crates/anthropic-agent-sdk/Cargo.toml`: already had its
`[[example]]` blocks pruned in the previous commit.
Lockfile pins (workspace `Cargo.lock`):
Pulling reqwest 0.12.28 (via anthropic-agent-sdk) into the
unified workspace dep graph re-resolved several `icu_*` crates to
the 2.2 line, which requires rustc 1.86. OP's toolchain is 1.85
(locked to stay compatible with the skia-safe-op fork). Pinned:
icu_collections 2.2.0 → 2.1.1
icu_locale_core 2.2.0 → 2.1.1
icu_normalizer 2.2.0 → 2.1.1
icu_normalizer_data 2.2.0 → 2.1.1
icu_properties 2.2.0 → 2.1.2
icu_properties_data 2.2.0 → 2.1.2
icu_provider 2.2.0 → 2.1.1
idna_adapter 1.2.2 → 1.2.1
All eight pins are the latest versions on each crate's 2.1.x /
1.2.x line that compile on rustc 1.85.
Verification:
- `cargo check -p anthropic-agent-sdk` ✓
- `cargo check -p copilot-sdk` ✓
- `cargo test --workspace --no-run` ✓
- `cargo test -p openpencil-shell-core --lib` → 250 pass
- `cargo test -p openpencil-desktop chat_` → 16 pass
Next: replace the hand-rolled subprocess parser in chat_subprocess.rs
with thin per-CLI adapters that route Claude Code through
`anthropic_agent_sdk::SubprocessTransport` and Copilot through
`copilot_sdk::Client + Session`. Gemini stays on the generic stdin
bridge until an upstream Rust SDK exists. Codex + OpenCode get an
HttpServerProvider that spawns `<bin> serve` then connects via a
local HTTP client.
Finishes the WIP that landed unfinished in fe47c901. The `PromptMode`
+ `find_binary` + `scrubbed_child_env` helpers all now flow through
`SubprocessProvider::send`:
- **PromptMode::PositionalArg** (Claude Code today, since `--print`
mode expects `-- <prompt>` argv and an empty stdin): the
user_message is appended to argv after `--`; stdin is closed
immediately without writing.
- **PromptMode::Stdin** (Gemini / Copilot / generic): argv passed
verbatim; user_message written to stdin then closed.
- Per-CLI templates in `for_cli` now select the right mode +
resolve the binary via `find_binary` (PATH probe + Windows
PATHEXT fallback + per-OS npm / yarn / bun / volta / homebrew
install locations from bartolli/anthropic-agent-sdk's
`find_cli`). Claude Code defaults bumped to
`--print --verbose --output-format stream-json` (the `--verbose`
flag was missing — Claude Code's stream-json output omits
granular event detail without it).
- `scrubbed_child_env()` builds the child env from the parent env
minus LD_PRELOAD / DYLD_INSERT_LIBRARIES / NODE_OPTIONS /
PYTHONPATH / PERL5LIB / RUBYLIB / LD_LIBRARY_PATH /
DYLD_LIBRARY_PATH so the spawned CLI can't be hijacked by
interposition vars left in the parent shell. `env_clear()` first
so tokio Command's default-inherit is overridden.
- Test assertions on `.binary` loosened to `.ends_with("claude")` /
`.ends_with("gemini")` etc. — `find_binary` returns an absolute
path when one of the well-known locations matches, so the bare
name check was failing on hosts that actually have these CLIs
installed. Behavior contract is "basename matches", not "exact
string equals", which the new assertions encode.
Cross-platform invariants preserved from fe47c901: Windows cmd /c
wrapping for PATHEXT, CREATE_NO_WINDOW for GUI builds, Unix
process_group(0) for signal isolation, `exit_status_label` for
signal-killed children on Unix.
Tests: 16 chat tests pass on macOS host with actual `claude` /
`gemini` binaries installed (the new code paths exercise PATH +
fallback resolution).
User direction: instead of hand-rolling subprocess JSON bridges in
`chat_subprocess.rs`, pull the community SDKs into vendor/ + adapt
them. Both repos are MIT-licensed Rust SDKs purpose-built for their
respective CLIs and ship more capable wire-protocol parsers than the
generic line-based approach in this branch's HEAD.
`vendor/anthropic-agent-sdk/` (was bartolli/anthropic-agent-sdk @ main,
2026-05-14):
- SubprocessTransport for `claude --print --verbose --output-format
stream-json -- <prompt>`
- Recognized message envelope (system / user / assistant / result
shapes per Claude Code's documented headless protocol)
- Binary-lookup fallback through ~/.npm-global/bin, /usr/local/bin,
~/.local/bin, ~/node_modules/.bin, ~/.yarn/bin (via `which` +
manual probe)
- Dangerous-env-var scrub (LD_PRELOAD / DYLD_INSERT_LIBRARIES /
NODE_OPTIONS / ...) for spawn safety
- CancellationToken-based abort wiring
- Trimmed locally: removed examples/, demos/, tests/, docs/, .git/.
Inner `[workspace]` block stripped so OP's root workspace owns the
build. `typed-builder` pinned to `=0.21.0` because upstream's
`0.23.2` uses stable `let`-chains (Rust 1.88+) and OP rust-toolchain
is 1.85 to stay compatible with the skia-safe-op fork.
`vendor/copilot-sdk-rust/` (was copilot-community-sdk/copilot-sdk-rust
@ main, 2026-05-14):
- LSP-style Content-Length-framed JSON-RPC over stdio for
`gh copilot` (the new community CLI that succeeds the legacy
`gh-copilot suggest` subcommand)
- Client + Session abstraction with event subscription
(`AssistantMessage` / `SessionIdle` / tool-use events)
- Trimmed: examples/, tests/, .git/ removed. Cargo.toml unchanged
(already 2021 edition + 1.85 rust-version + no problematic deps).
Workspace integration:
- Both directories appear in OP root Cargo.toml's `exclude` list so
`cargo build --workspace` doesn't try to compile them (each
declares its own `edition` / `rust-version` distinct from OP).
- openpencil-desktop will consume them via target-gated path deps
in the next commit + replace the hand-rolled provider in
`chat_subprocess.rs` with thin adapters that route per CliName:
Claude Code → anthropic_agent_sdk::SubprocessTransport
Copilot → copilot_sdk::Client + Session
Gemini → keep the generic stdin/stdout bridge (no upstream
Rust SDK exists yet for the gemini CLI)
Codex /
OpenCode → HttpServer bridge (separate, spawn `<bin> serve`
+ connect to local 127.0.0.1:port)
Per the user clarification "opencode 和 codex 我们调用 http server, 通过
ipc 启动本地的 server 模式": Codex + OpenCode stay on the HttpServer
path even though they're also spawned subprocesses — the local
server is what we IPC with via HTTP, not their stdio.
Standalone build verified for both vendored crates: ✓ check passes
on rustc 1.85.1 (this host).
Two fixes in one commit — codex re-review BLOCK + the user's request
to consider mac / Windows / Linux when doing IPC.
1. BuiltInProvider keeps the QueryEngine — multi-turn history works.
The previous commit (25aaacb8) rebuilt the engine per `send` so
per-request `system_prompt` + `max_output_tokens` would take
effect. Codex flagged this as a regression: each new engine
carries a fresh `MessageStore`, so the LLM saw only the current
user message and the chat had no memory across turns.
Reverted to a single `Arc<QueryEngine>` shared across every send.
System prompt + max-output-tokens are now constructor-only (the
TS app behaves the same way — they're settings, not per-message
inputs). Per-turn override is left as a future agent-rs API job.
2. Cross-platform IPC for SubprocessProvider — mac / Linux / Windows.
Address "ipc 的时候要考虑下 mac,windows,linux":
- **Windows binary lookup**: Win32 CreateProcessW does **not**
honor PATHEXT, so `Command::new("claude")` only spawns when an
exact `claude` (no extension) is on PATH. Real-world: npm /
bun / Volta / scoop install Node CLIs as `claude.cmd` /
`claude.bat` / `claude.ps1` shims. Without PATHEXT expansion
the spawn just fails with ENOENT.
New `build_command(binary, args)` helper. On Windows, routes
bare command names through `cmd /c <binary>` so PATHEXT kicks
in. Absolute paths + `.exe` skip the wrapper. Hardcoded
`for_cli` names are injection-safe; `with_binary` callers own
their input.
- **Windows console suppression**: `CREATE_NO_WINDOW`
(`0x0800_0000`) on the creation_flags so spawning the CLI from
a double-click-launched GUI build doesn't pop a black console
window.
- **Unix signal isolation**: child placed in its own process
group (`process_group(0)`). Ctrl-C in the terminal that
launched the GUI now only kills the GUI, not the mid-stream
CLI. The bridge's existing kill-on-receiver-drop covers
cleanup; we never depended on signal propagation.
- **Cross-platform exit status**: new `exit_status_label`
helper. On Windows `.code()` is always populated; on Unix
`.code()` is `None` when the child was killed by signal —
surface `"signal N"` in the chat error instead of the previous
opaque `"?"`.
The spawn-failure test was loosened to "at least one Error
delta + a terminal Done" because Windows routes through cmd /c,
which spawns successfully — the inner CLI then exits non-zero,
so the error surfaces from the exit-status path instead of the
spawn-error path. Both paths are tested implicitly across
targets; the test now passes on all three.
Tests: 16 chat tests still pass on macOS host. Windows + Linux
behavior is structural — the `#[cfg]` arms are exercised at compile
time on each target.
Codex review of 3d754fdc / 85d93e7c / 1b168a84 flagged six BLOCKs +
five CONCERNs + one NIT. This commit closes them.
BLOCKs (all six fixed):
1. stderr pipe not drained → CLI deadlocks on full pipe. Now spawned
sibling task drains stderr to /dev/null for the lifetime of the
child.
2. Receiver-drop only detected on next stdout line → idle CLI keeps
running forever. Switched both BuiltIn + Subprocess channels from
`std::sync::mpsc` to `tokio::sync::mpsc` so `tx.closed()` is a
future we can race against `lines.next_line()` in a `select!`.
Sync iterator wrapper `BlockingRecvIter` in chat_runtime.rs uses
`Receiver::blocking_recv`.
3. `Done` event marked the flag but didn't break the read loop. Now
breaks immediately on any structured `done` so stdout staying
open past turn-end doesn't hang the iterator.
4. EOF path always emitted `Done { EndTurn }`, ignoring child exit
status. Now reaps the child and surfaces non-zero exit as
`Error("CLI exited with status N") + Done { Aborted }`.
5. stdout read error fell through to `Done { EndTurn }`. Now emits
`Error + Done { Aborted }` so I/O failures aren't reported as
normal completion.
6. Malformed structured events silently produced empty deltas /
empty errors / nameless tool calls. `parse_line` now requires the
shape's mandatory fields and emits `Error("malformed X event:
...")` when they're missing. Done's `stop_reason` stays optional
(already had a sensible `EndTurn` fallback).
CONCERNs (4 of 5 fixed):
1. `ChatRequest.system_prompt` + `max_output_tokens` ignored by
BuiltInProvider. Now honored per-turn: each `send` builds a
turn-local `QueryEngine` cloning the provider Arc (cheap) +
applying request's system/max-tokens with constructor defaults
as fallback. `BuiltInProvider` struct now holds the pieces
instead of a pre-built engine.
2. BuiltIn `Error` path emitted no terminal `Done`. Now every
error-terminal branch sends `Done { Aborted }` so consumers can
distinguish "stream errored" from "channel silently closed."
3. Subprocess stdin write errors silently ignored. Now surfaces as
`Error("stdin write: ...") + Done { Aborted }` with child kill +
wait.
5. `SubprocessProvider::for_cli(Codex | OpenCode)` accepted the
wrong backend silently. Signature now returns `Option<Self>`;
HttpServer-category CLIs return `None`. Direct stdio bridging to
a `codex` binary still possible via `with_binary`.
CONCERN 4 (Subprocess silently drops `system_prompt` +
`max_output_tokens`) intentionally deferred — those fields have no
universal CLI mapping; the planned settings-modal flow lets users
encode them in argv directly via `with_binary`. Documented in the
module header as the contract.
NIT 1 (chat_provider.rs doc said "three" but listed four backends)
fixed.
Tests: 14 → 16 native chat tests + 250 shell-core tests pass. Two
new tests cover for_cli's `None` return for HttpServer kinds + the
malformed structured-event paths.
Second of the four backends from the user's correction
("可以通过 ipc 调用本地的 cli"). Pairs with the BuiltInProvider from
`85d93e7c`.
`crates/openpencil-desktop/src/chat_subprocess.rs`:
- `SubprocessProvider::for_cli(CliName)` builds a bridge for
ClaudeCode / Gemini / Copilot — each seeds the binary name from
`CliName::default_binary()` and a best-effort default argv
matching the CLI's stream-JSON / quiet / suggest mode. The argv
set will become user-tunable in the settings modal; today's
defaults are good enough for the common path.
- `with_binary(path, args, label)` for non-PATH installs (settings
modal will let users override claude → ~/bin/claude-beta).
- `ChatProvider::send` spawns the CLI with stdin / stdout /
stderr piped, feeds `request.user_message` + EOF, then reads
stdout line-by-line on the shared tokio runtime.
- `parse_line` recognizes 5 structured shapes
(`text` / `thinking` / `tool_use` / `done` / `error`) and
degrades gracefully — non-JSON lines + unknown JSON types surface
as raw `TextDelta` carrying the line + "\n" so CLIs like
`gh copilot suggest` that just stream plain stdout still show up
in the chat panel.
- Receiver-drop kills the child (`start_kill` then `wait`) so
navigating away mid-stream doesn't leak a hung process.
- Always emits a terminal `Done` (either from the CLI's `done`
event or, on stdout EOF without one, `EndTurn`).
`chat_runtime::shared_runtime` lifted to `pub(crate)` so the new
module reuses the process-wide tokio runtime instead of spinning up
its own.
Cargo:
- `tokio` features grow `io-util` + `process` (for `BufReader` +
`Command`).
Tests (9 added — all pass):
- 5 cover the line parser (text / thinking / tool_use / done /
error)
- 3 cover the fall-through paths (plain text, malformed JSON,
unknown type)
- 2 cover the `for_cli` defaults table
- 1 end-to-end test against a bogus binary path verifies the
spawn-error → `Error` + terminal `Done` contract
Together with `chat_runtime.rs` we now have 14 native chat tests
passing. HttpServer (Codex / OpenCode `serve`) + Acp (third-party
ndJSON-over-stdio) bridges land next.
The shell-core trait + `EchoProvider` from `3d754fdc` was the
abstraction. This wires up the first real backend so the AI chat
panel can drive a non-stubbed LLM turn from the native binary.
`crates/openpencil-desktop/src/chat_runtime.rs`:
- `BuiltInProvider` wraps `agent::QueryEngine` (the cross-product
Rust agent runtime at /Users/kayshen/Workspace/ZSeven-W/agent-rs).
- Process-wide tokio runtime singleton (multi-thread, `op-chat`
threads) initialized lazily on first send so cold chrome startup
doesn't pay for the spawn.
- Async → sync bridge: `ChatProvider::send` returns
`Iterator<Item = ChatDelta>`; the impl spawns a tokio task that
pumps agent-rs `Event`s into a `std::sync::mpsc::channel`, then
returns the receiver iterator. Closes on `Result` / `Error` /
receiver drop. Maps `TextDelta` / `Thinking` / `ToolUse` /
`Result` / `Error` straight to the corresponding `ChatDelta`
variants; `ToolResult` / `Usage` / `Notice` / `Unknown` swallow
silently (widget doesn't render them yet — they land in a Phase 2
transcript view).
- `map_stop_reason` table covers agent-rs's stop-reason strings
(`end_turn` / `stop_sequence` / `max_tokens` / `tool_use` /
`aborted` / `user_abort`); unknown values fall through to
`EndTurn` (safe default — turn over).
- `from_provider` is the constructor — takes any
`Arc<dyn Provider>` so tests + future settings-modal wiring (per-
provider credential modals) can drive in their own backend impls.
Cargo:
- `agent = { path = "../../../agent-rs/crates/agent",
default-features = false }` — no default features today because
the `anthropic` feature drags in reqwest's TLS stack (rustls /
icu_collections@2.2 / idna_adapter@1.2) which needs rustc 1.86
while this workspace pins 1.85. The BuiltIn trait + engine wiring
ship now; concrete Anthropic / OpenAI-compat / Ollama Provider
impls flip on once rust-toolchain bumps.
- `tokio` (rt-multi-thread + macros + sync) + `futures` for the
async bridge; `async-trait` for the test double's `Provider`
impl. All three are target-gated to native (cfg desktop OS) per
the workspace WASM-boundary policy in `Cargo.toml`.
Tests (3 added — all pass):
- `builtin_provider_streams_text_deltas_through_iterator` — drives
a scripted `Provider` test double through the engine, asserts
`ChatDelta::TextDelta("Hello")` arrives first and the run ends
with `Done { stop_reason: EndTurn }`.
- `builtin_provider_surfaces_event_error` — `Event::Error` from the
provider lands as a `ChatDelta::Error` carrying both code +
message.
- `map_stop_reason_table` — exhaustive table of every variant +
unknown fallthrough.
Next: Subprocess / HttpServer / Acp bridges per the 4-backend taxonomy
in `project_agent_runtime` memory — each lives in its own module so
the 800-line cap stays honored.
User correction: the previous `chat_provider.rs` (commits `548c5336`
+ `2c2b7f60`) assumed a direct-HTTP-per-provider model with
Anthropic / OpenAI-compat / Gemini / etc. each carrying its own
endpoint + model defaults. That's not the architecture decision —
per the project_agent_runtime memory, OP runs FOUR distinct
backend categories:
- BuiltIn → `agent-rs` QueryEngine in-process (the
cross-product Rust agent crate; lives at
/Users/kayshen/Workspace/ZSeven-W/agent-rs)
- Subprocess(Cli) → spawn `claude` / `gemini` / `gh-copilot` +
talk line-delimited JSON over stdio
- HttpServer(Cli) → spawn `codex serve` / `opencode serve` +
hit local HTTP endpoint with reqwest
- Acp → Agent Client Protocol (ndJSON over stdio),
the open extension point for third-party
agents OP doesn't ship a dedicated adapter for
API rewrite in `chat_provider.rs`:
- `CliName::{ ClaudeCode, Gemini, Copilot, Codex, OpenCode }`
enumerates the 5 first-party CLI backends. Each carries
`label()` (human display), `default_binary()` (PATH lookup),
and `backend()` (which Subprocess/HttpServer transport it
uses — table matches the memo verbatim).
- `ChatProviderKind::{ BuiltIn | Subprocess(CliName) |
HttpServer(CliName) | Acp }` replaces the previous flat
Anthropic/OpenAI-compat/etc enum.
- `ChatProviderConfig::new(kind)` pre-fills `binary` from
`default_binary()` for Subprocess/HttpServer kinds; BuiltIn
and Acp leave it empty.
- `ChatProvider` trait + `EchoProvider` test double unchanged.
The streaming `ChatDelta` shape stays close to agent-rs's
`stream::Event` (TextDelta / Thinking / ToolUse / Done /
Error + StopReason variants).
- Real transport implementations live in the future
`pen-agent-cli` desktop crate per the memo — shell-core stays
wasm32-clean (no tokio / reqwest / process-spawn).
Tests (4 new + 1 carried):
- cli_name_backend_table_matches_architecture_memo (verbatim
map from the memo's table)
- cli_default_binary_uses_expected_names
- provider_config_new_seeds_binary_for_cli_kinds (BuiltIn / Acp
leave it empty)
- cli_label_is_human_readable
- echo_provider_replays_script (carries the test double's
behavior forward)
Tests total: 250 shell-core. Wasm32 build clean.
Closes the last data-shape gap before the per-kind specialised
mappers (`figma-fill-mapper`, `figma-stroke-mapper`, `figma-text-
mapper`, `figma-vector-decoder`, etc) start porting. Given a
parsed clipboard entry, mint a Document `Node` with a fresh id,
the right `NodeKind`, and the right human-readable name.
`FigmaClipboardNode::to_node(&self, next_id) -> Node`:
- Mints id from caller-supplied allocator, bumps next_id.
- `NodeKind` via the existing `to_node_kind()` mapper.
- Name: `self.name` if present, else `self.kind.to_lowercase()`
so the layer panel always has something to render (a Figma
layer named just "" still gets "rectangle" / "ellipse").
- Geometry / fill / stroke stay at defaults — those are the per-
kind specialised mappers' job.
Tests (2 new):
- Two sequential calls mint 100 + 101 from a 100 seed; final
next_id = 102. First node's name copies through; both have
`NodeKind::Rect` from the `RECTANGLE` kind string.
- Empty name → kind-derived fallback (`"ellipse"`).
#9 Figma now ~60% — file recognition + clipboard JSON walker +
per-child kind+name extraction + NodeKind mapping + Node
construction. Remaining: per-kind geometry/fill/stroke/text
specialised mappers (the 14 `pen-figma` converter files), Zstd
decompression for binary `.fig`, schema-encoded body parsing.
Tests total: 250 shell-core (+2). Wasm32 build clean.
Adds the data shapes the agent-settings Agents tab + the chat
panel's provider picker need. Six backend kinds covering every
provider the TS app exposes:
- Anthropic (Claude direct API)
- OpenAiCompat (OpenAI / Anthropic-via-proxy / Ollama / local)
- Gemini (Google generative AI)
- Copilot (GitHub Copilot)
- OpenCode (the dedicated Codex-style provider)
- Ollama (local server convenience default)
`ChatProviderConfig { kind, api_key, endpoint, model }` carries
the per-provider settings the chat panel persists.
`default_endpoint(kind)` + `default_model(kind)` pre-fill the
endpoint + model inputs from a TS-mirrored table (Anthropic →
claude-sonnet-4-6; Ollama → llama3.2; etc).
Tests (2 new):
- default_endpoint + default_model match the TS table for a
sampled subset (Anthropic + Ollama)
- `label()` returns human-readable strings (`"Claude"`,
`"GitHub Copilot"`) for the picker dropdown
#6 AI chat now ~35% — types + trait + chat-widget integration +
provider config + per-kind defaults. Real HTTP transport for each
kind (reqwest + per-API serialisation) is the remaining multi-week
core work; the data shapes the transport plugs behind are all in
place.
Tests total: 248 shell-core (+2). Wasm32 build clean.
`get_document_info` reports page count + active page index + total
node count over the registry. Smallest of the ~20 tools TS pen-mcp
exposes; serves as the wire-format smoke test for real LLM clients
and demonstrates the registration shape future tools follow.
- `GetDocumentInfo { page_count, active_page_index, total_nodes }`
is a snapshot struct — pre-computed at registration so each
`dispatch` is O(1). Mutates as documents change requires
re-registering (the server binary will do this on every doc
edit; v1 ships a frozen snapshot).
- `document_info_snapshot(&Document)` walks every page +
recursively counts subtree nodes. Container nodes count as
themselves + their descendants (matches TS's
`flattenNodes(...).length`).
Tests (1):
- Build a 3-node doc (Frame + 2 children), snapshot, register,
dispatch, verify each field round-trips through the JSON-RPC
response.
#7 MCP now ~60% — types + registry + wire format + stdio listener +
first registered tool. Remaining: ~19 more first-party tools
(insert_node, batch_design, design_skeleton, ...) — each a focused
follow-up using the same `McpTool` impl shape.
Tests total: 246 shell-core (+1). Wasm32 build clean.
Advances #9 from "we know how many children" to "we know what each
child is named + what its Figma kind string is". `FigmaClipboardNode
{ kind, name }` carries the minimal fields the next conversion
stage (kind → PenNode variant + name → Node.name) needs.
`extract_clipboard_nodes` walks each top-level `{...}` block in the
children array via `collect_top_level_blocks` (depth-1 tracking +
string-aware brace counting), then `extract_string_field` pulls
`"type"` and `"name"` off each block. Missing fields yield empty
strings so the caller can `unwrap_or` without panics.
`ParsedFigStub` gains `clipboard_nodes: Vec<FigmaClipboardNode>`.
The existing `top_level_children` count stays as a duplicate of
`.clipboard_nodes.len()` for callers that only need the size.
Tests (2 new):
- Real-ish 3-child payload (RECTANGLE, TEXT, FRAME with nested
ELLIPSE) → only 3 top-level extracted, names match, nested
avatar NOT promoted to top-level.
- Missing fields → empty-string defaults without panic.
#9 Figma now ~35%. Remaining: map kind → PenNode variant (the
17-file `pen-figma` port: figma-node-mapper + 14 specialised
converters for fills / strokes / text / vectors / layout). Each
mapper is a focused follow-up; the structural parse + identification
groundwork is in place.
Tests total: 243 shell-core (+2). Wasm32 build clean.
Two final pieces of the Variables/Themes data layer:
- `stroke_refs: BTreeMap<NodeId, String>` parallels `fill_refs`.
Paint reads `var_table.stroke_color_for(node.id)` first, falls
back to `node.stroke.color` otherwise. Both fields registered in
the canonical loader when it sees a `$ref` on the stroke
descriptor in `.op` files.
- `set_active_theme(axis, value)` + `clear_active_axis(axis)`
mutators give the future theme-picker widget a one-line entry
point. `clear_active_axis` removes the axis from the active
map so subsequent resolutions fall back to the variable's
`theme: None` default — matches TS theme-axis reset behavior.
Tests (2 new):
- `stroke_color_for_resolves_registered_ref` — register a Color
variable, register a stroke ref, resolve through the table,
verify blue channel matches `#0000ff`.
- `set_active_theme_round_trips_through_axis_picker` — flip an
axis, add a second, clear one, assert state matches.
#5 Variables/Themes now ~98% — only the panel widget UI is
pending. Plumbing for paint, mutation, loader integration, fill
and stroke ref resolution all working with structural tests.
3 existing codegen test fixtures patched to seed `stroke_refs:
BTreeMap::new()` alongside `fill_refs`.
Tests total: 241 shell-core (+2). Wasm32 build clean.
Wires the existing chat widget to the `ChatProvider` trait
(commit `548c5336`). The old `send()` echo-stub stays for the
zero-provider fallback path; `send_via_provider(provider,
system_prompt, max_tokens)` is the real entry that:
- Pushes the input as a `ChatRole::User` message
- Builds a `ChatRequest` and calls `provider.send(req)`
- Drains the delta iterator into a single accumulated assistant
message (TextDelta + Thinking concatenate; Error replaces with
"error: ..."; Done breaks the loop; ToolUse is ignored — that
flow belongs to the agent runtime port)
- Returns the delta count for streaming-progress reporting
Synchronous wrapper — the caller polls the iterator to completion.
A future async-capable widget can replace the inner loop with one
delta per render frame; the trait signature already returns an
`Iterator + Send`.
Tests (3): EchoProvider streams two text fragments → assistant
body "Hello, world!"; provider error surfaces as assistant body
"error: rate limited"; empty input is a no-op.
#6 AI chat now ~25% — types + trait + chat-widget integration
working end-to-end with a test double. Real HTTP transport
(reqwest + Anthropic / OpenAI-compat / Ollama backends) is the
remaining multi-week port — but the seam is in place.
Tests total: 239 shell-core (+3). Wasm32 build clean.
`paint_fill_then_stroke` now takes the resolved fill explicitly
rather than reading `node.fill` directly. Callers in `paint_node`
pre-resolve via `node_fill(node, var_table)` which checks
`var_table.fill_for(node.id)` first, falling through to `node.fill`
otherwise. Result: a Frame / Rect whose canonical loader registered
a `$ref` for its fill paints the current themed value at runtime;
flipping `active_theme` repaints with the new colour.
- `paint_fill_then_stroke` signature: adds `fill: Option<Color>`
as the last arg (after world_rect + zoom).
- Both `NodeKind::Frame` + `NodeKind::Rect` branches in
`paint_node` now compute `node_fill(node, var_table)` before
calling the helper.
#5 Variables/Themes: types + storage + canonical loader +
fill_refs map + resolve + paint-time substitution all working.
Variables panel UI (active-theme picker + variable list with
edit) is the remaining piece — that's a widget, not a model
change.
Tests total: 236 shell-core (no new assertions in this commit;
the existing 8 variable tests cover the resolution chain that
paint now consumes). Wasm32 build clean.
Plumbing for paint-time `$ref` substitution. `paint_node` now takes
`&VariableTable` alongside the existing args; recursive calls
pass it through unchanged. The new `node_fill(node, var_table)`
helper resolves `var_table.fill_for(node.id).or(node.fill)` —
ready for paint sites to swap in.
Full substitution still requires `paint_fill_then_stroke` /
icon_font branches to call `node_fill(node, var_table)` instead of
reading `node.fill` directly — that's a focused refactor (changes
the helper's signature in `canvas_viewport_overlay.rs` + every
NodeKind branch in paint_node) and lands separately. With the
plumbing in place today, the helper switch is a single per-site
edit; no more API reshape needed.
#5 Variables now ~90% — types, storage, loader, fill_refs map,
paint plumbing all shipped. Only the per-site `node.fill →
node_fill(node, var_table)` substitution remains.
Tests total: 236 shell-core. Wasm32 build clean.
Advances #9 from "magic-byte detection only" to "we can read
something useful from the JSON clipboard format". `parse_fig` on a
`{"type":"FIGMA_DOCUMENT","children":[...]}` payload now returns
the count of top-level children entries instead of just
`NotYetImplemented`.
Hand-rolled JSON walker (shell-core stays serde-free for wasm32
bundle size): tracks brace depth + string-quote state to count
exactly the `{` openings at depth-1 inside the `"children": [`
array. Robust against quoted braces in node names + arbitrary
nesting inside each top-level child.
`ParsedFigStub` gains `top_level_children: usize`. Binary `.fig`
path stays `NotYetImplemented` — Zstd decompression + the
schema-encoded body need their own focused work (likely a server-
side binary or a desktop-only adapter, since adding `zstd-sys` to
shell-core would inflate the wasm32 bundle).
Tests (3 new):
- 3-entry children array → count 3
- Nested objects inside each top-level → only top-level counted
- Quoted brace in a string value → not counted
#9 Figma now ~20%. Real Figma → PenNode mapping (the 17-file
pen-figma port: fig-parser + figma-node-mapper + 14 specialised
converters) remains the multi-week core work.
Tests total: 236 shell-core (+3) + 20 native + 8 desktop = 264.
Wasm32 build clean.
`mcp::run_stdio(registry, reader, writer)` reads line-delimited
JSON-RPC requests, dispatches each through the registry, writes
the wire-formatted response with a trailing `\n`, flushes after
each line. Loops until EOF or write error.
Generic over `BufRead` + `Write` so the same function powers:
- The eventual `openpencil-mcp` binary (`stdin().lock()` +
`stdout()`).
- Test fixtures using `Cursor<&[u8]>` + `Vec<u8>`.
- Future TCP-listener wrappers.
Malformed input is skipped silently — the loop survives garbage
lines so a misbehaving client can't kill the server. Production
deployments will want logging here; the stub leaves that hook for
the binary.
Tests (2 new):
- Three-line stream (two valid + one unknown-tool) produces
three responses, ids preserved (`1`, `2`, `"x"`), error code
`-32601` for the UnknownTool case.
- Mixed garbage + blank + valid stream produces one response
matching the single valid request.
#7 MCP now ~50% — types + registry + wire format + listener loop.
Remaining: real tool implementations (insert_node, batch_design,
design_skeleton, etc) + the binary entry. Each tool is a focused
follow-up; the dispatcher is done.
Tests total: 233 shell-core (+2). Wasm32 build clean.
Deep-clones a registered Component's root subtree with fresh
`NodeId`s and appends to the active page's top-level children.
Mirrors TS drag-from-Components-panel insertion + the right-click
"Insert Instance" path.
- `Document::instantiate_component(component_id, next_id) ->
Option<NodeId>` — looks up `doc.components`, deep-clones root
via `clone_node_with_new_ids` (private walker), pushes to
`active_page().children`, sets the new root as selection
anchor, captures pre-state to history (one entry per insert).
- `next_id` allocator threaded through so every node in the
cloned subtree gets a unique id past `max_node_id() + 1`,
matching the same guard `duplicate_selected` /
`group_selected` use.
Tests (2 new):
- Component with 2 children → instance with same shape, both
children have fresh ids (≠ source 11, 12), selection lands
on instance root, history grew by one.
- Unknown component id → None (no-op, no history).
#8 Components now ~65% — types + storage + create + instantiate
flow all shipped. UI hookup (Components panel widget + right-
click "Insert Instance" + drag-drop into canvas) is the remaining
follow-up.
Tests total: 231 shell-core (+2). Wasm32 build clean.
#5 Variables advances toward 90% with `VariableTable.fill_refs:
BTreeMap<NodeId, String>` — node-id → variable-name map for fills
that should resolve through the variable table instead of using
`node.fill` directly. Avoids touching `Node`'s shape (which would
invalidate every Node literal across test fixtures + builders) by
piggybacking on the same VariableTable the canonical loader fills.
API additions on `VariableTable`:
- `set_fill_ref(node_id, ref_name)` — register a node's fill ref
- `fill_for(node_id) -> Option<Color>` — resolve through the
current `active_theme`; falls back to None when no ref is
registered or the variable doesn't resolve. Canvas paint will
call this first, fall through to `node.fill` on None.
Side-derivation: `NodeId` now derives `PartialOrd + Ord` so it
can key into `BTreeMap`. The existing 3-codegen-test fixtures
gain a `fill_refs: BTreeMap::new()` initializer.
Tests (2 new):
- `fill_for_resolves_registered_node_ref_to_themed_color`:
register a Themed Color variable + a node ref, flip
`active_theme`, verify the resolved Color tracks the theme
- `fill_for_returns_none_when_no_ref_registered`: unknown
NodeId returns None so paint falls back to direct fill
Canvas-side `paint_node` integration (`let fill =
doc.var_table.fill_for(node.id).or(node.fill);`) lands when the
canvas refactor for that path arrives next.
Tests total: 229 shell-core (+2). Wasm32 build clean.
Bridges the gap between the in-memory `ToolCall` / `ToolResponse`
types and on-the-wire JSON-RPC frames. Pure Rust, no serde dep
(shell-core stays wasm32-clean — adding serde would inflate the
bundle for a feature only the server binary uses).
- `response_to_json(&ToolResponse) -> String` — emits the
standard `{"jsonrpc":"2.0","id":...,"result":...}` for OK and
`{"jsonrpc":"2.0","id":...,"error":{"code":...,"message":...}}`
for Err. Hand-rolled emitter with proper JSON escaping for
`"`, `\`, `\n`, `\r`, `\t`, and control chars.
- `parse_tool_call(&str) -> Option<ToolCall>` — minimal parser
that extracts `id` / `method` from a single-line JSON-RPC
request. Empty `arguments` map for now; the server binary
will swap in a real serde parse when wired.
- `error_code_to_int` — maps `ToolErrorCode` variants to
JSON-RPC's reserved + application-range codes per the spec
(-32600..-32603 transport, -32001..-32002 application).
Tests (4 new):
- Ok response carries `"jsonrpc":"2.0"`, the right id, and the
result map serialised correctly.
- Err response carries the right error code (-32601 for
UnknownTool) and message.
- Round-trip: parse_tool_call → registry.dispatch → response_to_json
preserves the request id through the full pipeline.
- JSON escapes special chars (`"`, `\n`) in both id and message.
#7 MCP now ~30% — types + registry + wire format. Real stdio
listener (line-delimited JSON over stdin/stdout) lives in the
follow-up server binary.
Tests total: 227 shell-core (+4) + 20 native + 8 desktop = 255.
Wasm32 build clean.
Adds the "Save as Component" mutator — promotes the anchor-selected
Frame/Group to a registered Component in `doc.components`. Same
shape as TS app's right-click "Make Component" context-menu action.
Semantics:
- Selection must be exactly one node (anchor); anchor selection
is the natural target for a "save as component" gesture.
- Kind must be `Frame` or `Group` (loose shapes need to be
wrapped first; matches TS).
- The node stays on the page; the library entry is a clone.
- Returns the new component id (== source node id), None on
rejection.
Tests (3 new):
- happy path: Frame → component registered, node still on page
- non-container rejection: Rect selected → None, lib empty
- no-selection no-op: clear_selection → None
#8 Components now at ~50% — library + storage + create flow. UI
(right-click menu wire-up, Components panel for browsing) and
NodeKind::Instance variant (for component-instance nodes on the
canvas) remain.
Tests total: 223 shell-core (+3) + 20 native + 8 desktop. Wasm32
build clean.
- `React` — JSX functional component named `Page` wrapping nodes in a
fragment; inline `style={{}}` objects with camelCase keys.
- `Flutter` — `Stack(children: [Positioned(left, top, child:
Container/Text)])`. Color emits as `Color.fromARGB`.
- `SwiftUI` — `ZStack { Rectangle()/Ellipse()/Text(...) .frame.position
}`. Color emits as `Color(red, green, blue, opacity)`.
All three reuse the established `Codegen` trait + walk
`doc.pages[active].children` the same way the HTML / Vue / Svelte
emitters do — `hidden` nodes are skipped, children recurse, text
bodies escape special chars (HTML targets) or use Dart/Swift
string-literal-escaping (`{:?}` debug-fmt) for compiled targets.
Tests (4 new):
- React: import statement + component declaration + JSX fragment
- Flutter: Stack wrapper + Positioned/Container shape + ARGB color
- SwiftUI: ZStack + Rectangle + .frame modifier
- SwiftUI ellipse: NodeKind::Ellipse → `Ellipse()` view
Remaining 2 generators (Compose, React Native) ship in a follow-up
commit — both follow the same trait-per-target shape with their
specific framework's geometry primitives.
Tests total: 218 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
Adds `Vue` and `Svelte` codegen targets alongside the existing
`CssVariables` and `Html`. Both reuse `emit_node_html` for markup
and embed the `CssVariables` generator's output verbatim in their
`<style>` block, so design tokens flow through into the framework
output as CSS custom properties.
- `Vue` — Vue 3 SFC: `<template>` (node markup) + `<script setup
lang="ts">` placeholder + `<style scoped>` (variables).
- `Svelte` — Svelte SFC: `<script lang="ts">` placeholder + bare
markup (no wrapping template tag, per Svelte convention) +
`<style>` (variables). Script-then-style order enforced by
test.
Tests (3 new):
- `vue_emits_template_script_style_blocks` — all three SFC
sections present
- `svelte_emits_script_then_markup_then_style` — script appears
before style in output (positional check)
- `vue_includes_variable_css_in_style_block` — variables flow
through into the scoped style block
5 generators remain (React + Tailwind, Flutter, SwiftUI, Compose,
React Native). The HTML-derivable group is done; the remaining 5
are framework-specific component models that need their own
emitters.
Tests total: 214 shell-core (+3) + 20 native + 8 desktop. Wasm32
build clean.
Adds `codegen::Html` alongside the existing `CssVariables` generator.
Walks `doc.pages[active].children` and emits absolute-positioned
`<div>` per Rect/Frame/Group + `<span>` per Text, with inline-style
position/size, RGB fill, stroke as border, corner radius (50% for
ellipses), and rotation transform. Body text is HTML-escaped.
API stays identical — both generators implement the same
`Codegen` trait, so a future CLI / Property-panel codegen
dispatcher fans out by trait object.
Tests (4 new):
- DOCTYPE + body wrapper + generator-attribution comment present
- Rect emits `<div>` with left/top/width/height + `rgb(r,g,b)` fill
- Text emits `<span>` and `&` / `<` / `>` in body get HTML-escaped
- `node.hidden = true` skipped entirely (no orphan markup)
7 generators remain to port (React + Tailwind, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) — each as a focused
follow-up commit. The trait + dispatcher shape doesn't change.
Tests total: 211 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
Codex stop-gate (round 2 on the same surface): the previous fix
gave `McpTool::call` access to `&ToolCall` so a well-behaved tool
COULD echo `request.id` — but nothing made it do so. A buggy /
adversarial tool was still free to mint a fake id, and id-mismatch
silently broke JSON-RPC routing on the client side.
Refactor: change the trait return type from `ToolResponse` (id +
payload) to a content-only `ToolOutcome::{ Ok(map) | Err(code,
msg) }`. The registry's `dispatch` wraps the outcome with the
originating `call.id` to produce the on-wire `ToolResponse`. Tools
never see the id; id-mismatch is now structurally impossible.
- New `ToolOutcome` enum sits between tool implementations + the
wire-shape `ToolResponse`.
- `McpTool::call(&self, args: &BTreeMap<String, String>) ->
ToolOutcome` — args-in, outcome-out, id-blind.
- `ToolRegistry::dispatch` constructs `ToolResponse::Ok { id:
call.id, result }` and `ToolResponse::Err { id: call.id, ... }`
from the outcome.
- `EchoTool` updated to the new signature.
- New `LyingTool` fixture deliberately ignores any context the
registry might pass; `registry_forces_id_on_response_regardless_of_tool`
asserts the response still carries `req-honest` even though
LyingTool's `call` returns an empty content map.
Tests: 4 MCP tests pass (3 carried over + 1 new id-stamping
regression). 206 shell-core total. Wasm32 build clean.
BLOCK #1 — anchor drag couldn't return to start. `apply_cursor_move`
only called `set_path_anchor_position` when the cursor doc-point
differed from `start_doc`, so dragging away and then BACK onto the
original point silently skipped the final write — release committed
history with the anchor stuck at the last off-start frame.
Fix: always write the cursor position during an active drag; use
the start-doc comparison only to flip `moved` (which gates history
push). Regression test `anchor_drag_back_to_start_lands_at_start`
simulates the round-trip and asserts the anchor follows the cursor
all the way home.
BLOCK #2 — MCP tool registry dropped the request id. `McpTool::call`
only received `&BTreeMap<String, String>`, forcing tools to invent
response ids (test double used `RequestId::Num(0)`). JSON-RPC + MCP
require every response to echo the originating request id. Fix:
change the trait signature to `call(&self, request: &ToolCall) ->
ToolResponse` and have `dispatch` forward the whole call. EchoTool
updated to read `request.id`; the registry test now asserts the
id round-trips.
BLOCK #3 — opening a native saved file leaked variables across
documents. `apply_payload` reset pages + history + selection but
never touched `doc.var_table` or `doc.components` (both added in
recent commits). Open a variable-bearing canonical `.op`, then
open a plain saved `.pen` — codegen would still emit the stale
canonical variables. Fix: `apply_payload` now reassigns both to
`Default::default()` after the page/UI reset block.
Tests: 206 shell-core + 20 shell-native (+1 anchor return) + 8 desktop.
Wasm32 build clean.
#10 on the TS-parity roadmap starts. User directive (memory
project_op_rust_gap_priority) is "codegen last"; CSS Variables is
the simplest and complements the #5 Variables/Themes work already
shipped this session — it emits whatever lands in
`doc.var_table` as a stylesheet without requiring the rest of the
node tree.
API:
- `crate::codegen::Codegen` trait — `target_label()` +
`generate(&Document) -> String`. Pure: no file I/O.
- `CssVariables` impl — walks `doc.var_table.variables` and emits
`:root { --name: value; }` for scalar entries, plus
`:root[data-axis="value"] { ... }` blocks for each themed
combination. CSS ident sanitisation maps non-alphanum chars to
`-` so `primary.color` → `--primary-color`.
Tests (4):
- emits scalars (`#0066ff`, `12`) under a `:root` block
- per-theme variables emit one block per axis combo, both light
and dark CSS variables present
- non-ident chars sanitised (`primary.color` → `primary-color`)
- empty doc emits only the generator header comment
Remaining 8 generators (React + Tailwind, HTML, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) ship in follow-up
commits; the `Codegen` trait means each is a focused new file.
Tests total: 198 shell-core (+4). Wasm32 build clean.
Translates a `$ref` variable name straight into a paintable
`crate::Color`. Gates on `VariableKind::Color`; rejects non-Color
variables, unparseable strings, and any non-Str scalar. Lenient
hex parser handles `#rgb` / `#rrggbb` / `#rrggbbaa` (case-insensitive),
rejects anything else.
This is the function paint-time `$ref` substitution will call from
the canvas viewport — once `Node` carries an optional ref name
alongside its direct fill (next session's model change for #5),
paint reads `node.fill_var.as_ref().and_then(|n| doc.var_table.resolve_color(n))`
falling back to `node.fill`. The Color helper is the pure piece;
the Node-level field addition is the invasive piece.
Tests (4):
- resolve_color_parses_rrggbb_hex — `#ff8040` round-trips
- resolve_color_picks_themed_active_value — `mode: dark` picks
the dark entry of a Themed Color variable
- resolve_color_rejects_non_color_variables — Number/Bool/String
variables return None even with a hex-looking value
- resolve_color_rejects_invalid_hex — `not-hex` returns None
Tests total: 190 shell-core (+4) all pass.
Mirrors the var_table wiring from commit e81e8e09. Adds the
component-library field to Document so the canonical .op loader
(and future "Save as Component" mutator) has a place to land
component definitions.
- `Document.components: ComponentLibrary` — Default = empty.
- Patched the same 9 test fixtures (`tests_geometry.rs` × 7 +
`canvas_viewport.rs` + `layer_panel_tests.rs`) to seed the
field alongside `var_table`.
- Library lookup methods (`find_by_id` / `find_by_name` /
`insert` with id-replace) are usable on `doc.components` from
anywhere.
Pen-doc-adapter integration (`pen_document_to_payload` doesn't yet
carry components) + `NodeKind::Instance` variant for cross-document
instances + Components panel widget all stay pending — the data
shape lands first so the loader integration is one focused commit.
document.rs / mutators.rs both back at the 800-line cap via
doc-comment compaction (Node field docs dropped to inline naming,
`t()` Doc compressed to one line).
Tests: 187 shell-core (no new assertions; the 3 components tests
landed in commit 0fecab0a). Wasm32 build clean.
Drops the storage layer for #8 Components onto shell-core so the
canonical `.op` loader has somewhere to land design-system data
when it ships. Same scaffold-first pattern Variables/Themes used.
New file `document/components.rs`:
- `Component { id, name, root: Node }` — one reusable design
fragment; `root` is the subtree future Instance-NodeKind will
clone on insert.
- `ComponentLibrary { components: Vec<Component> }` — per-document
registry. `find_by_id` / `find_by_name` for lookup;
`insert(c)` replaces on duplicate id (TS parity with the
'Save as Component' overwrite path).
Re-exported from `crate::document::{Component, ComponentLibrary}`.
Document field wiring + canonical loader integration land in a
follow-up alongside the `NodeKind::Instance` variant (needs the
match-arm sweep across canvas paint / pen_doc_adapter / serializer).
Tests (3): find_by_id matches by id; find_by_name returns the
first hit; insert replaces an existing entry on id collision.
document.rs back at the 800-line cap (was 805 after the mod
declarations) via doc-comment compaction on Node — fields kept
self-documenting via name.
Tests total: 187 shell-core (+3) + 19 shell-native + 8 desktop.
Wasm32 build clean.
Compact three multi-line comment blocks in `widget_host/input.rs`
that were narrating implementation details Codex already covers
inline elsewhere:
- path-anchor `moved` flag explanation: 4 lines → 1
- align-toolbar hover sync rationale: 4 lines → 1
- settings-input keyboard ownership: 3 lines → 1
Net 8 lines saved; file is still 78 over the 800-line cap because
the remaining bulk is real code (apply_text dispatch, apply_release
drag-clearing chain, apply_cursor_move's 6-branch drag detection).
A proper sibling-module split — moving keyboard handlers to
`widget_host/keyboard.rs` and clipboard ops to `widget_host/clipboard.rs`
— is task #49 and stays a clean focused refactor for the next pass.
Tests: 19 native still pass. No behavior change.
Closes the gap between `VariableTable` (commits `62b08b93` /
`dbfd2f1a`) and the canonical `.op` loader: opening a file now
preserves `.variables` + `.themes` straight onto
`Document.var_table` instead of dropping them at the door.
- `Document.var_table: VariableTable` field — Default = empty
table. Patched 9 test fixtures (`tests_geometry.rs` × 7 +
`canvas_viewport.rs` + `layer_panel_tests.rs`) to seed with
`VariableTable::default()` alongside `history`.
- `pen_doc_adapter::build_var_table(&PenDocument) -> VariableTable`
maps `jian_ops_schema::variable::*` → shell-core types: the
enums are isomorphic (Color/Number/Boolean/String;
Bool/Num/Str; Scalar/Themed). Theme axes copy through directly.
- `persistence.rs` open path: when the canonical branch fires,
`build_var_table` runs alongside `pen_document_to_payload`. The
result is held in a local `Option<VariableTable>` because
`apply_payload` doesn't know about variables; after it resets
the document, the held value is assigned to `doc.var_table`.
Round-trip: load → `doc.var_table.find("color-1")` returns the
right `Variable`, `doc.var_table.resolve("color-1")` returns the
themed value under the current `active_theme` (empty default —
picks the `theme = None` entry, mirroring `Variable::resolve`'s
fallback). UI for switching `active_theme` (Variables panel) +
paint-time `$ref` substitution are the remaining follow-ups for #5.
Tests: 184 shell-core + 19 shell-native (no new assertions in this
commit; variables algorithm tests landed in `dbfd2f1a`). Desktop
builds clean.
Codex stop-gate BLOCK #1: `boolean_ops::apply_boolean_op` looked up
source paths recursively (via `active_page().find()`) but only
removed them from the top-level `page.children` list. When the
sources lived inside a Group or Frame, the originals stayed in
their parent's children while the result was appended at the
canvas root — duplication + orphans.
Fix: replace the top-level `retain` call with a recursive
`remove_nodes_recursively` walker that drops any node whose id is
in the source set from every children Vec depth-first. New
regression test `boolean_op_removes_nested_paths_not_just_top_level`
wraps two paths in a Group, runs Union, and asserts:
- the Group still exists but is empty
- one result Path lives at top level (total page.children = 2)
Codex stop-gate BLOCK #2: `property_panel_sections::export_section_rect`
was added in commit `5bde95b9` (PropertyPanel preview pills) but
never wired into `hit_test_action`, leaving the visible Export
section unable to open the new ExportDialog. The helper is dead
code in the meantime. Drop it; replace with a comment marking the
follow-up. Existing UX (File menu → Export image / Cmd+Shift+P)
still opens the dialog. Tracking via task #52.
Codex stop-gate finding #3 (`main.rs` 828 / `input.rs` 886 over
the 800-line cap) is real but stylistic — already tracked as task
#55 (sibling-module split). No functional impact; deferred so this
commit stays scoped to the data-corruption + dead-code fixes.
Tests: 184 shell-core + 19 shell-native (+1 nested boolean ops
regression). Wasm32 build clean.
Adds a `VariableTable { variables, themes, active_theme }` struct
that owns the canonical `.op` variable + theme registry. Looks up
by name (`table.find("color-1")`) and resolves through the active
theme (`table.resolve("color-1") -> Option<&VariableScalar>`).
`active_theme` is a `BTreeMap<String, String>` mapping axis to
selected value (e.g. `{"mode": "dark"}`); empty map falls back to
the default `theme = None` entry of every Themed variable.
Both `Variable::resolve` (single var) and `VariableTable::resolve`
(registry-level) are now testable in isolation; the canonical
loader's job in the next session is to populate one of these
tables from `PenDocument.variables` + `.themes`.
Document field wiring deferred: 9 test fixtures construct
`Document { ... }` literals across `tests_geometry.rs` /
`layer_panel_tests.rs` / canvas viewport tests; threading a new
field through them is a separate, mechanical commit. Until then,
the variable table lives as a free-standing type that adapters can
hold on the side.
Tests: 184 shell-core pass; both `document.rs` + `mutators.rs`
sit at exactly the 800-line cap.
Lays in the data shapes the canonical `.op` loader needs to
round-trip designs that depend on `$ref` color tokens + multi-axis
themes. Mirrors `jian_ops_schema::variable` so loader integration
in a follow-up is a direct field map.
New types in `document/variables.rs` + re-exported from
`crate::document`:
- `VariableKind { Color, Number, Boolean, String }`
- `VariableScalar { Bool(bool), Num(f64), Str(String) }` (untagged
over the on-disk `"#ff0000"` / `12.5` / `true` shapes)
- `ThemedValue { value, theme: Option<BTreeMap<String, String>> }`
— one entry per (axis, value) combination
- `VariableValue::Scalar | Themed(Vec<ThemedValue>)`
- `Variable { name, kind, value }` — owns its resolution: passes
an `active_theme: &BTreeMap<String, String>` and returns the
`VariableScalar` whose theme map is the subset that matches.
Falls back to the entry with `theme = None`. Empty `Themed([])`
returns None.
- `ThemeAxis { name, values }` — placeholder for the Variables
panel's theme picker (paint UI lands later).
Tests (4):
- scalar_variable_resolves_regardless_of_theme — `$color-1`
always returns the literal regardless of active axis.
- themed_variable_picks_active_axis — light/dark color tokens
resolve correctly for each `mode`.
- themed_variable_falls_back_to_default_when_no_match — `theme:
None` is the safety net when active axis isn't enumerated.
- themed_variable_returns_none_when_empty_and_no_default — empty
`Themed([])` returns None instead of panicking.
Follow-ups:
- Wire `Document.variables: Vec<Variable>` + `.themes` +
`.active_theme` fields (waiting on cap headroom in document.rs).
- Canonical loader integration: `pen_doc_adapter` reads
`PenDocument.variables / .themes` into the new fields.
- Paint-time `$ref` resolution: walk nodes pre-paint, replace
`Color { ref: ... }` with the resolved variable scalar.
- Variables panel widget in the Right rail.
Tests: 184 shell-core (+4) all pass. document.rs back at 800-line cap
after compacting FileAction + BooleanOp doc comments.