3 Commits

Author SHA1 Message Date
Marcos Paulo 238249309f chore: release 0.6.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 05:37:26 -03:00
Marcos Paulo 35dc8aed8f docs: agents and annotations guides, specs, mock-ups, 0.6.1 checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 05:37:20 -03:00
Marcos Paulo 73353dd4ca feat: any agent via ACP, annotations with templates, chat fixes
Chat: agents are found on the login-shell PATH, so apps launched from
the Dock see claude, codex and Homebrew CLIs. A generic Agent Client
Protocol backend runs OpenCode, Gemini CLI, Cursor, Pi (pi-acp) and
others from a data registry, plus custom agents; papercraft tools are
auto-approved only when announced and not destructive. New agent panel
and Settings > Chat layout, sign-in card, several @ selections, no
install instructions. Fixes Send silently doing nothing when the CLI
path was set in Settings.

Annotations: numbered notes with leader lines that explain a design.
Categories drive the look; templates (project default, per-page) choose
which kinds are used. Annotate tool (N), side view, Customize, inspector
section, Annotate with AI, view modes (Cmd+Shift+A). MCP: annotate
(batched), get/update/delete, get/set_annotation_template; notes appear
in get_design_spec and get_jsx and in agent-run review and revert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 05:37:20 -03:00
91 changed files with 7562 additions and 759 deletions
Generated
+1 -1
View File
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"arboard",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.6.0"
version = "0.6.1"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
+11 -1
View File
@@ -1,6 +1,6 @@
# Progress
Last updated: 2026-10-21.
Last updated: 2026-10-22.
## Phase 3: local-first design and whiteboard tool
@@ -76,6 +76,16 @@ are credited in `THIRD_PARTY.md`.
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
moving layers between pages; remote image URLs (see README Known gaps).
### 2026-10-22: 0.6.1 (agents via ACP, annotations)
* Agents are found through the login shell's PATH (read once at startup, 3 s limit, merged with the app PATH and the usual folders), so an app opened from the Dock finds Claude Code, Codex and the rest. No install instructions anywhere; a missing agent shows Not found with Set path… and Docs.
* ACP backend (`src/chat/acp.rs`): any agent that speaks the Agent Client Protocol over stdio, with PaperCraft's MCP server, streamed updates, permission flow, cancel and load. Registry as data in `src/chat/agents.rs`: native agents found on PATH, npx agents off until enabled, custom agents by name, command and arguments. Verified here: OpenCode works; Gemini CLI's server currently refuses ACP clients; Cursor needs `cursor-agent login`; Pi works through `pi-acp` after enabling.
* Agent panel over the chat column (Detected, Enable via npx, Not found, API endpoints, Custom, search), a Connect an agent setup card, a sign-in card, and multiple `@ Selection` chips.
* Settings → Chat redesigned: one agents list with details, endpoints, defaults, scrolling page on one label grid.
* Send bug: a turn that cannot start keeps the typed text and shows an error row with Retry, never a silent no-op (`a_failed_start_keeps_the_text_and_shows_an_error`).
* Annotations: notes with leader lines on a page, six categories with keys (style comes only from the category), project and per-page templates with built-ins and a Customize modal, view modes (⌘⇧A), Annotate tool (N), side view, inspector section, Annotate with AI, six MCP tools (`annotate` batches), and comments in `get_design_spec` and the code exports. Exports and Present leave annotations out.
* Evidence: `src/chat/{path,agents,acp}.rs`, `src/ui/{chat_agents,annotations,annotations_canvas}.rs`, `src/model/annotation.rs`, `src/editor/annotations.rs`, `src/mcp/tools.rs`, `tests/annotations*.rs`, `specs/chat-agents/`, `specs/annotations/`, `docs/chat.md`, `docs/annotations.md`.
### 2026-10-21: Chat mode
* Chat layout next to the canvas (⌘J, `Design | Chat`), with three backends: Claude Code and Codex through their CLIs (`claude -p` stream-json, `codex exec --json`) against the app's MCP server, and the Anthropic API with a key in the OS credential store. One event stream for all three; tool calls run through the same MCP dispatch, so runs, review and revert behave as before.
+8 -3
View File
@@ -90,7 +90,7 @@ Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file
## Agents
PaperCraft exposes **61 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
PaperCraft exposes **67 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
| Way | Notes |
|---|---|
@@ -138,7 +138,7 @@ EOF
### Chat
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code or Codex through their CLIs with PaperCraft's MCP server, or the Anthropic API with your own key, and it shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code and Codex through their CLIs, any agent that speaks the Agent Client Protocol (ACP, such as OpenCode, Gemini CLI or Cursor) found on your PATH, or the Anthropic API with your own key. It shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
<p align="center">
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left shows a finished agent run with its Review, Revert and Accept actions, and the canvas on the right" width="100%">
@@ -146,6 +146,10 @@ Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to
<sub>Chat mode with a finished agent run: review, revert or accept from the thread.</sub>
</p>
### Annotations
Annotations are notes that explain a design in place: business rules, behaviour, data sources, accessibility and dev notes, each a numbered card with a leader line to its layer. Categories set the look, templates say which notes a page wants (product spec, dev handoff, accessibility review and more), and the view cycles between full notes, badges and hidden with ⌘⇧A. Exports and Present mode leave them out. Agents add them in one `annotate` call. See [docs/annotations.md](docs/annotations.md).
### Skills
[`skills/`](skills) holds agent skills: `papercraft` (the build, screenshot, lint, fix loop and tool cheat sheet), `papercraft-design` (design-quality rules) and `papercraft-critique`. [docs/agents.md](docs/agents.md) is the full guide: install, registering with agents, scripts, runs and review, safety. [docs/design-system.md](docs/design-system.md) covers variables, components and libraries.
@@ -223,6 +227,7 @@ The scenario tests drive the editor with synthetic pointer and keyboard events;
| ⌘= / ⌘- / ⇧0 | Zoom in / out / 100% | ⇧1 / ⇧2 | Zoom to fit / selection |
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
| ⌘J | Design / Chat mode | ⌘L | Add selection to chat (Chat mode) |
| N | Annotate tool | ⌘⇧A | Annotations: full, badges, hidden |
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
@@ -237,7 +242,7 @@ Draw a frame around existing shapes and it adopts them; dragging a layer into or
* **Variables:** gradients keep fixed colors; there are no text styles and no component variants or properties.
* **Booleans are destructive:** the result is a new vector path with flattened curves.
* **`write_html`** ignores margin, tables and transforms (it reports them as `ignoredStyles`) and does not fetch remote images; pass a local path or a `data:` URI.
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. The in-app Chat mode runs Claude Code, Codex or your own Anthropic key; other agents run outside and connect over MCP or the CLI.
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. The in-app Chat mode runs Claude Code, Codex, ACP agents or your own Anthropic key. Agents that are not in Chat connect over MCP or the CLI.
* **Sharing** has no live presence (cursors, selections) yet.
* **Pen pressure** is simulated from stroke velocity on macOS tablets, because winit 0.30 does not expose it for mouse-like pen events.
* **Releases** cover macOS (Apple silicon) and Linux x86_64. Windows packaging scripts exist but are not part of releases.
+9 -2
View File
@@ -2,7 +2,7 @@
PaperCraft is an early alpha: a local-first infinite-canvas design app that agents can drive. This
file lists what is still missing, in priority order, with an honest status. Done items are checked
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 7, 8, 12, 15, 23, 25, 32, 34, 39, 40, 44, 45, 46 and section 6 were updated 2026-10-21. Milestone history is in
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 7, 8, 12, 15, 23, 25, 32, 34, 39, 40, 44, 45, 46 and section 6 were updated 2026-10-21; rows 15, 47 and 48 on 2026-10-22. Milestone history is in
[PROGRESS.md](PROGRESS.md); known rendering and import limits are in the README.
Status: **done** (works today), **partial** (some of it works), **todo**.
@@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | Item | Status | Notes |
|---|---|---|---|
| 15 | MCP and CLI tool surface | **done** | 61 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 15 | MCP and CLI tool surface | **done** | 67 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
@@ -86,3 +86,10 @@ Spec: [specs/agent-runs/spec.md](specs/agent-runs/spec.md), tasks: [specs/agent-
| 43 | Comments assigned to agents | todo | `@agent` in a thread triggers an edit and a reply. Needs threaded comments. |
| 44 | In-app prompt bar | **done** | Chat mode: Claude Code, Codex or your own Anthropic key; see `docs/chat.md`. |
| 45 | Correct tool annotations | **done** | `lint_design` no longer sends `readOnlyHint`; `fix: true` edits are classified. |
## 7. Annotations and chat agents
| # | Item | Status | Notes |
|---|---|---|---|
| 47 | Design annotations | **done** | Notes with leader lines and categories, templates per project and page, view modes (⌘⇧A), Annotate tool (N), side view, inspector section, six MCP tools; see `docs/annotations.md`. |
| 48 | ACP agents in Chat | **done** | Any ACP agent on the PATH (OpenCode works; Gemini CLI refuses ACP clients for now; Cursor needs `cursor-agent login`), npx agents after enabling, custom agents; see `docs/chat.md`. |
+12 -2
View File
@@ -1,6 +1,6 @@
# Driving PaperCraft with agents
PaperCraft exposes one set of 61 tools three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
PaperCraft exposes one set of 67 tools three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
| Way | Use it when | Needs the app running |
|---|---|---|
@@ -8,7 +8,7 @@ PaperCraft exposes one set of 61 tools three ways. They all run the same code in
| MCP over stdio, `papercraft-mcp` | the client only starts stdio servers | for calls; `tools/list` works offline |
| Shell, `papercraft-cli` | the agent has a terminal and you want no tool schemas in its context | for `call`; `list`, `schema` and `icons` work offline |
If you would rather talk to the agent inside the app, Chat mode (⌘J, or `Design | Chat` in the top bar) is the in-app alternative. It runs Claude Code or Codex through their CLIs against the same MCP server, or the Anthropic API with your own key, and keeps the conversation next to the canvas, with the same runs, review and revert. The setup and the thread are in [chat.md](chat.md). The rest of this guide covers driving the app from your own terminal or agent client.
If you would rather talk to the agent inside the app, Chat mode (⌘J, or `Design | Chat` in the top bar) is the in-app alternative. It runs Claude Code or Codex through their CLIs against the same MCP server, ACP agents such as OpenCode or Gemini CLI, or the Anthropic API with your own key, and keeps the conversation next to the canvas, with the same runs, review and revert. The setup and the thread are in [chat.md](chat.md). The rest of this guide covers driving the app from your own terminal or agent client.
## Install
@@ -112,6 +112,16 @@ EOF
* Components: `get_components` lists mains of every page and enabled library with a `componentKey`; `create_instance` and `swap_instance` accept a main from any page or library (`componentId` or `componentKey`).
* Libraries: `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`. They work offline too, on the project store.
## Annotation tools
* `annotate {pageId?, notes: [{node, text, type?}]}` adds many notes in one undo step. `node` is a node id or an exact layer name; the first line of `text` is the label and the rest the body; `type` is a category key (default `rule`) that the page's template must enable. Placement is automatic.
* `get_annotation_template {pageId?}` returns the allowed types with their guidance and fields. Call it once before `annotate`.
* `set_annotation_template {template, scope?: "page" | "project", pageId?}` switches the template by id or name.
* `get_annotations {nodeId?, pageId?, full?}` reads notes as `[number, node, type, text]` rows.
* `update_annotation {id | ref, text?, type?}` and `delete_annotations {ids}` take ids or references such as `Sign in #3`.
See [annotations.md](annotations.md) for categories, templates and what the notes look like.
## Safety
The server listens on loopback only, rejects requests that carry an `Origin` header (browsers) and foreign `Host` headers. Anything on your machine that can reach `127.0.0.1:29980` can edit the open project, so do not forward the port. Undo (⌘Z, or the `undo` tool) reverts any agent edit.
+128
View File
@@ -0,0 +1,128 @@
# Annotations
An annotation is a note that explains a design in place: a business rule, a behaviour, where the data comes from, an accessibility gap. Each note is a small card with a number, a category, a label and an optional body, joined to its layer by a leader line. Teams use them for review and handoff, and turn them off for presenting.
Annotations live on a page and are saved with it (the `annotations` list in the page file). Pages without any load and save exactly as before. Categories and templates are project-wide and live in `design.json`.
## Categories
Every note has one category. The category is the only thing that sets how a note looks: its icon, colour and badge come from the category, and a note stores none of them. Change a category's colour or icon and every note in it changes.
Each category has a short type key. Agents and the Annotate popover use the key; the UI shows the name.
| Key | Name | Icon | Colour | Use for |
|---|---|---|---|---|
| `rule` | Business rule | scale | violet `#7c5cff` | the rule or constraint the part enforces |
| `behaviour` | Behaviour | zap | blue `#2f80ed` | states, transitions, edge cases |
| `content` | Content | type | green `#1fa87a` | copy, tone, length |
| `data` | Data | database | amber `#e08a1e` | where a value comes from |
| `a11y` | Accessibility | accessibility | pink `#d94f8a` | focus, labels, contrast, reading order |
| `dev` | Dev note | code | grey `#6b7280` | implementation notes for engineers |
You can add categories, rename them, change their key, colour and icon, and write guidance for each one in the Customize modal. A custom category gets a key too.
## Templates
A template says which kinds of notes a page wants, and what each note should contain. The project has a default template; a page can use its own instead.
Built-in templates are editable copies, not locked. Each category in a template has enabled or not, guidance (a short prompt for what the note should say) and optional fields (structured lines such as `Owner`).
| Template | Categories | Fields |
|---|---|---|
| Product spec | rule, behaviour, content, data | none |
| Dev handoff | behaviour, data, dev, a11y | none |
| Accessibility review | a11y | Issue, WCAG, Fix |
| Content review | content | Tone, Max length |
| Minimal | rule | none |
Pick the project default or "This page" from the template chips at the top of the Annotations side view. Built-ins can be reset; custom templates can be deleted. Duplicate copies any template so you can change it.
**Customize…** (side view) opens the template editor: name, display (full notes or badges), the enabled categories, the guidance and fields of each. Changes to categories apply to the whole project.
The Annotate popover offers only the enabled categories of the active template, and shows each field as a labelled line. A field is stored as a `Name: value` line at the top of the note's body. Agents get the same rule: `annotate` refuses a type the template disables and lists the allowed keys.
## View modes
Three levels, all view state. They are not saved in the design and not undoable.
* **Full**: every note shows its label and the first lines of its body.
* **Badges**: only the number badges are shown.
* **Hidden**: nothing is drawn.
`⌘⇧A` (View → Annotations) cycles Full → Badges → Hidden → Full. The starting mode follows the template's display setting until you change it.
Two finer controls:
* **Per note**: collapse a note to its number badge; click the badge to expand it again.
* **Per category**: show or hide a category from the category filter in the side view.
Annotations are drawn as an overlay on the canvas only. Exports (PNG, SVG, PDF) and Present mode never include them. There is no option to include them in an export yet.
## Annotate tool
Press `N` for the Annotate tool, then click a layer. The New annotation popover opens beside it with:
* Category, from the template's enabled categories.
* Label, up to 40 characters. The popover shows the count.
* The template's fields, if any.
* A note, in Markdown.
Save creates the note with its leader line to the layer's side; Cancel discards it. You can also select layers, right-click, and choose **Annotate**.
Each note's leader line starts at the note card and ends at the anchor on the target. The card and the anchor are stored relative to the target, so moving or resizing the layer keeps the note attached. Drag a card to move it; the line re-routes.
If the target is deleted, the note stays where it was in the side view's **Detached** group, with a warning badge.
## Annotations side view
The side view lists the page's notes:
* Grouped by top-level frame, plus **Page** (notes on no frame) and **Detached**.
* Each row shows the number, the category chip and the label. Click a row to select the layer and zoom to it.
* The category filter shows or hides categories.
* Edit a note inline, or delete it. Edits are one undo step each.
* **Add annotation** opens the popover for the current selection.
* **Annotate with AI** puts a prompt in the Chat composer, unsent, with the selected frame (or the page) as a chip. The prompt asks the agent to read the template with `get_annotation_template` and add all the notes in one `annotate` call. It needs a saved project and Chat mode.
Numbers run per frame in reading order (top to bottom, then left to right), so a frame reads 1, 2, 3. Notes on no frame are numbered after that, from 1.
## Inspector
When a single layer is selected, the inspector has an Annotations section. It lists that layer's notes, with edit and delete, and an Add annotation button. The note's anchor is one of the four sides of the target (Top, Right, Bottom, Left) with a position along it.
## Agents
Agents work with notes through six MCP tools, all in `src/mcp/tools.rs`. They are one undo step per call and appear in `edits_doc`, so a run that only adds notes still gets a Review card (a "Notes" group, counted as added, changed or removed), and Revert removes them.
| Tool | What it does |
|---|---|
| `get_annotation_template` | Reads the template: `[type key, guidance, field names]` for each enabled type. Call it once before `annotate`. |
| `set_annotation_template` | Switches the template by id or name, for one page (default) or the project (`scope: "project"`). |
| `annotate` | Adds many notes at once. Each note names a node id or an exact layer name on the page, a `text` and a `type` (default `rule`). |
| `get_annotations` | Reads notes as `[number, nodeName, type, text]` rows (text capped at 120 characters). `full: true` returns complete objects; `nodeId` limits to a subtree; `pageId` picks another page. |
| `update_annotation` | Changes the text or type of one note, by `id` or by reference such as `Sign in #3`. |
| `delete_annotations` | Deletes notes by ids or references such as `Sign in #3`. |
For `annotate` and `update_annotation`, the first line of `text` is the label and the rest is the body. There are no positions, icons or colours: PaperCraft places each note beside its target, outside the frame, stacked so none overlap. `annotate` is all or nothing: if one note is refused (an unknown layer, or a type the template disables), none is added and the error lists the allowed keys.
Batched call, on a page whose template is Product spec:
```json
{
"notes": [
{"node": "Sign in", "type": "rule", "text": "Lock after 5 failed tries\nLocks for 15 minutes, then resets."},
{"node": "Email field", "type": "data", "text": "Source: users.email\nValidated on blur"},
{"node": "Submit", "type": "behaviour", "text": "Disabled until both fields are valid\nSpinner while the request runs"}
]
}
```
The reply is one row per note, `[number, node, type, label]`. Then `get_annotations {}` reads them back, and `delete_annotations {"ids":["Sign in #3"]}` removes one by its frame reference.
**Handoff.** `get_design_spec` and the code exports (`get_jsx`) include the notes of the frame as comments. Each note is one line, `// 1 Business rule: Lock after 5 failed tries`, with its body lines as more `//` lines, so code generated from a frame carries the rules next to the markup.
## Storage
* The page file has an `annotations` list. Each note holds its target (or a point, when it has none), its anchor, label, body, category, position, author (`user` or `agent`) and creation time. A note whose target is gone is marked `detached`.
* `design.json` holds the categories and the templates, with the project default. A page may name its own template.
* Old projects load unchanged. A page without annotations saves byte-identical to before.
+64 -21
View File
@@ -8,18 +8,35 @@ Chat mode is native only. The web build shows the switch disabled with a note.
## Backends
Chat drives the same tools as an agent connected over MCP, so the edits, runs, undo and revert behave the same whichever backend you use. There are three kinds of backend.
Chat drives the same tools as an agent connected over MCP, so the edits, runs, undo and revert behave the same whichever backend you use. There are three ways in: two CLIs that PaperCraft talks to directly, any agent that speaks ACP, and API endpoints.
**Claude Code** runs `claude -p` with streaming JSON in and out. PaperCraft writes an MCP config that points the CLI at the app's own server on `127.0.0.1`, allows the `mcp__papercraft__*` tools, and passes `--strict-mcp-config`, so the CLI sees only PaperCraft's server. The PaperCraft skill text goes in through `--append-system-prompt-file`. The working directory is the project folder. Follow-up messages resume the CLI session by its id, so the agent keeps its context.
**Codex** runs `codex exec --json` in the project folder. The PaperCraft server is added with a `-c mcp_servers.papercraft.url=...` override, and its tools are pre-approved (`default_tools_approval_mode="approve"`), so Codex does not stop to ask for each one. The PaperCraft instructions are prepended to the first prompt of a chat; resumed sessions do not repeat them. Follow-ups resume the Codex session the same way.
**ACP agents** run any agent that speaks the Agent Client Protocol (https://agentclientprotocol.com) over stdio. PaperCraft starts the agent once per turn, sends `initialize` and `session/new` with the project folder as the working directory, and passes PaperCraft's MCP server: over HTTP when the agent supports it, otherwise through the bundled `papercraft-mcp` stdio bridge. The prompt goes out as `session/prompt`, and the agent's streamed updates become the same thread as the other backends: text, thinking, tool cards and the plan. Stop sends `session/cancel`. Follow-ups resume with `session/load` when the agent supports it. ACP has no system prompt field, so the PaperCraft instructions go in front of the first prompt.
The ACP agents PaperCraft knows about, and what was checked on the development Mac:
* **OpenCode** (`opencode acp`): works.
* **Gemini CLI** (`gemini --acp`): found and started, but its server currently refuses ACP clients, so turns fail with the agent's error until Google changes that.
* **Cursor** (`cursor-agent acp`): sign in first with `cursor-agent login`.
* **Pi** (`pi-acp`, an adapter run through `npx`): enable it once in the agent panel or Settings, then it works like the others.
* **Goose, Kimi, Devin, Junie, Mistral Vibe, Hermes, OpenHands, Stakpak, VT Code, Poolside, Droid**: started with the command their own docs give. They are not all verified here.
* **Claude Code and Codex ACP adapters** (`npx`): listed under Enable via npx. Claude Code and Codex themselves use the direct backends above.
Agents that run through `npx` are off until you enable them, because enabling runs code that npx fetches from npm.
ACP permissions: PaperCraft's own tools run without asking, except the destructive ones listed under Approval below. Any other tool the agent asks to run (its own shell or file tools, for example) shows an approval card with Approve or Deny in the thread, and Deny is sent back to the agent.
**Custom agents** are your own ACP agents: a name, a command (a program name on the resolved `PATH`, or a full path) and arguments. The arguments are split on spaces, with no quoting. Custom agents appear under Custom in the agent panel and in Settings → Chat.
**API endpoints** call a model over HTTPS with your own key. Settings → Chat holds a list of endpoints, each with a name, a format, a base URL, a model and an optional key. The app's tool list is sent as the API tools, and every tool call runs through the same code path as an MCP call on the UI thread. The loop continues until the model ends its turn, for up to 40 tool rounds per message.
* **Format**: `Anthropic` is the Messages API (`POST {base}/v1/messages`, `x-api-key`). `OpenAI` is Chat Completions (`POST {base}/chat/completions`, `Authorization: Bearer`, streaming `choices[].delta`, tools as `{"type":"function",...}`, tool results as `tool` messages). Both give the same thread: text, tool cards, approvals, Stop, the usage line.
* **Base URL and model** are free text, for example `https://api.anthropic.com`, any Anthropic-compatible URL, or `http://127.0.0.1:11434/v1`. The key is optional, so local servers need none.
* A default endpoint named `Anthropic` (`https://api.anthropic.com`, `claude-opus-5-5`) exists from the start, so the first run needs only a key.
* The agent picker in the chat header lists Claude Code, Codex, then every endpoint by name. Each chat remembers its endpoint.
* The agent panel lists Detected agents, API endpoints and Custom agents (see Setup). Each chat remembers its backend.
* **Claude-only fields** (effort, the thinking summary, the refusal fallback and its beta header, eager tool-input streaming, prompt-cache breakpoints) are sent only when the base URL host is `api.anthropic.com`. For that host the model menu offers `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5`, effort is `medium` by default (`low` and `high` in the menu), and the Thinking row shows the summary. Every other endpoint gets a plain request. Missing `usage`, thinking or cache fields in the reply are fine.
* **OpenAI format and images**: attached images are sent as `image_url` data URIs. A tool message in this format carries text only, so a tool result with an image (such as `get_screenshot`) goes back as the text `[image omitted]`, and the image follows as an extra user message in the same round.
* **Usage line**: input, output and cached tokens, as the server reports them; nothing when it reports none. A cost estimate shows only for the Claude models in the small price table in `src/chat/endpoint.rs`. It is not a bill.
@@ -27,60 +44,85 @@ Chat drives the same tools as an agent connected over MCP, so the edits, runs, u
## Setup
Chat looks for `claude` and `codex` on `PATH`. A missing CLI is not an error: its row says `Not found` and shows the install command:
### Finding agents
* Claude Code: `npm install -g @anthropic-ai/claude-code`
* Codex: `npm install -g @openai/codex`
Chat finds agents the way your terminal does. At startup PaperCraft reads your login shell's `PATH` once, off the UI thread (`$SHELL -l`, 3 s limit), and merges it with the app's own `PATH` and the usual folders (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `~/.cargo/bin`, `~/.bun/bin`, `~/.npm-global/bin`, Volta and nvm). Detection and the agent processes both use that merged `PATH`. This is why an agent installed under `~/.local/bin` or Homebrew is found even when PaperCraft was opened from the Dock.
Install one of them, or add a key, then press Detect again on the Chat page. Settings → Chat shows each CLI found with its version.
A row shows a version when the agent reports one. A program with the right name that is not the agent (for example the `goose` database migration tool, which shares its name with Block's Goose agent) shows `Wrong program` with its path, not a false Ready.
The Chat page also holds:
PaperCraft never shows install commands. An agent that is not found shows `Not found`, with `Set path…` (opens Settings) and `Docs` (the agent's own documentation). Install the agent with its own installer, then press Detect again.
* **Paths**: an override for the `claude` and `codex` binaries, for when they are not on `PATH` or you want a specific install. Empty means the first one on `PATH`.
* **Default backend**: the backend a new chat starts with. The agent picker in the chat header changes it per chat.
### Agent panel
Click the agent chip in the chat header. The panel opens over the chat column with a shadow and a dimmed backdrop, sized like the column. It has a search field (name or id), and these sections:
* **Detected**: agents found on your `PATH`, with the version and path. Click one to use it for this chat.
* **Enable via npx**: agents that start through `npx` (Pi, Qwen Code, Kilo, Cline, Auggie, Copilot CLI, Qoder, CodeBuddy and the Claude Code and Codex ACP adapters). Each row has an `Enable` chip. The section says that it runs code fetched by npx.
* **Not found**: registry agents that are not on this computer, folded by default.
* **API endpoints**: your endpoints, with an `Add` action that opens Settings.
* **Custom**: `Add custom agent` opens Settings.
Each row has a status chip: `Ready`, `Enable`, `Not found`, `Wrong program`, `No key` (an endpoint without a key) or `Set up` (an endpoint without a URL or model). Arrow keys move through the rows, Enter picks one, Escape closes the panel.
Settings → Chat has the same list, so an agent can also be hidden from the panel there (the `Show in the chat agent picker` switch on its row).
### Setup card
A chat with no backend chosen shows one card, "Connect an agent". It lists the agents detected on this computer, up to four, with `Show all agents` for the rest. Below them are `Add an API endpoint` and `Add custom agent`. The card never lists install steps.
### Sign-in
When an agent says it needs you to sign in (ACP's "Authentication required" and similar messages), the failed turn shows "<Agent> needs you to sign in" with the command from the agent's docs, then Retry. For Cursor that is `cursor-agent login`, for OpenCode `opencode auth login`, for Codex `codex login`, for Claude Code's ACP adapter `claude /login`. For agents without a known command the card says to sign in with the agent's own app and retry. Chat does not sign in for you.
### Settings → Chat
The page scrolls and keeps one grid for its labels. Its sections:
* **Agents**: one row per agent, with its status, version and path. A details disclosure holds the **Path override** (for a binary that is not on `PATH`, or a specific install; empty means the first match on the merged `PATH`), the **Arguments override** (replaces the registry's ACP arguments, for example `--acp --debug`), and for npx agents the **Enable** button. The **Show in the chat agent picker** switch hides an agent from the panel. Custom agents have Name, Command and Arguments fields and a Remove button. `Detect again` re-runs detection.
* **API endpoints**: add, edit and remove endpoints (name, format, base URL, model) and set the default one. Each has a key field with Save and Remove. Keys are stored in the macOS Keychain, Windows Credential Manager or the Linux Secret Service, under the service name `PaperCraft` and the user `api-key:<base URL>`. An older single key (`anthropic-api-key`) is still read for `api.anthropic.com` and moves to the new entry the next time you save it. Removing a key makes that endpoint stop until a key is saved again (for `api.anthropic.com`; other endpoints work without one).
* **Defaults for new chats**: the backend, the endpoint, and the effort for `api.anthropic.com`.
Settings from before endpoints existed (`api_model`) move into the default endpoint on first load. These defaults and the endpoint list (never the keys) are saved in `chat_settings.json` in your config folder (`~/Library/Application Support/papercraft/` on macOS). They are not part of any project.
Settings from before endpoints existed (`api_model`) move into the default endpoint on first load. The `claude_path` and `codex_path` settings from earlier versions still win for their agent until you save the Agents list. These defaults, the agent settings and the endpoint list (never the keys) are saved in `chat_settings.json` in your config folder (`~/Library/Application Support/papercraft/` on macOS). They are not part of any project.
## The thread
The header has the chat title (rename), the chat list (new chat, switch, delete), the agent picker (for example `Claude Code · Opus 5.5`) and the context meter. The meter shows the tokens in the context at the last model call against a 200k window, and turns amber past 75%. It is hidden for Codex, which reports no per-call usage.
The header has the chat title (rename), the chat list (new chat, switch, delete), the agent chip (for example `Claude Code · Opus 5.5`) and the context meter. The meter shows the tokens in the context at the last model call against a 200k window, and turns amber past 75%. It is hidden for Codex, which reports no per-call usage.
Replies are markdown: paragraphs, lists, code blocks with a copy button, inline code and links. While a turn runs, the reply streams in.
* **Thinking**: when the backend reports reasoning, a row shows "Thinking…" while it runs and collapses to a summary when it ends. Expand it to read the text.
* **Tool cards**: one card per tool call with the verb and target (for example "Drawing layout · Card"), a spinner while it runs, and a failed state with the error. Args and result are collapsible. `get_screenshot` results show a thumbnail.
* **Step groups**: consecutive tool calls in one reply fold into one line ("Built the card · 5 steps") that expands to the cards.
* **Plan**: when the agent keeps a todo list (Claude Code's `TodoWrite`), the latest list is pinned above the composer with a "done of total" count.
* **Approval**: on the API backend, the destructive tools (`delete_pages`, `disable_library`, `import_design_system`, and `delete_nodes` on more than 20 layers) wait for Approve or Deny before they run. Deny returns an error to the model, which can then carry on. The CLI backends do not show this card. They use the CLI's own permission rules, and in non-interactive mode a tool that needs permission is denied. The papercraft tools are pre-allowed on both CLIs.
* **Plan**: when the agent keeps a todo list (Claude Code's `TodoWrite`, or an ACP plan), the latest list is pinned above the composer with a "done of total" count.
* **Approval**: on the API backend and for ACP agents, the destructive tools (`delete_pages`, `disable_library`, `import_design_system`, and `delete_nodes` on more than 20 layers) wait for Approve or Deny before they run. Deny returns an error to the model, which can then carry on. For ACP agents, any non-PaperCraft tool the agent asks for shows the same card. The Claude Code and Codex CLIs do not show this card. They use the CLI's own permission rules, and in non-interactive mode a tool that needs permission is denied. The papercraft tools are pre-allowed on both CLIs.
* **Checkpoint**: when an agent run starts, the thread shows "Checkpoint · before run N". "Restore to here" reverts that run and every run after it, through the same revert as the Agent view.
* **Run card**: when a run ends, "Agent finished, N changes" with Review, Revert and Accept. These are the same actions as the run review on the canvas.
* **Errors**: a failed turn shows a red row with Retry. A missing CLI or key shows a setup row instead.
* **Errors**: a failed turn shows a red row with Retry. A sign-in problem shows the sign-in card (see Setup).
* **Canvas follow**: when a tool call targets a node, the canvas pans to it, unless you moved the camera in the last 10 seconds.
## Composer and attachments
Type in the composer, ⏎ sends and ⇧⏎ adds a line. The empty state offers suggestions built from the project, such as "Design a sign-in screen using this project's variables" or "Lint this page and fix warnings".
* **Selection**: the `@ Selection` chip adds the current selection to the message (also ⌘L). It is sent as a short line with the layer name and keys.
* **Selection**: the `@ Selection` chip adds the current selection to the message as a chip (also ⌘L). Each click adds another chip, labelled with the layer name or `<first> · N layers`; an identical selection is added once, and each chip is removed on its own. Every chip is sent as a short line with the layer name and node ids.
* **Start failures**: if a turn cannot start (agent missing, bad path), an error row with Retry shows in the thread and your text stays in the composer.
* **Images**: the image button opens a file picker. You can also paste a clipboard image or drop image files on the column (PNG, JPEG, GIF, WebP).
For the API backend, images are sent as image blocks, so the model sees them. For the CLIs, each image is saved under `.papercraft-chat-tmp/` in the project and its path is added to the prompt for the agent to read.
For the API backend, images are sent as image blocks, so the model sees them. For the CLIs and ACP agents, each image is saved under `.papercraft-chat-tmp/` in the project and its path is added to the prompt for the agent to read.
While a turn runs, the send button becomes **Stop**. Stop asks the backend to interrupt: the CLIs get an interrupt, or SIGINT, then a kill after 3 seconds. Every backend ends within 3 seconds. Tools that already ran stay applied, and the partial run can be reviewed or reverted like any other.
While a turn runs, the send button becomes **Stop**. Stop asks the backend to interrupt: the CLIs get an interrupt, or SIGINT, then a kill after 3 seconds; ACP agents get `session/cancel`, then a kill if they do not exit. Every backend ends within 3 seconds. Tools that already ran stay applied, and the partial run can be reviewed or reverted like any other.
## Storage
A chat is one file in the project: `chats/<id>.json`, holding the title, the backend (kind, model, effort, CLI session id) and the messages with their tool calls, usage and run ids. The API backend also keeps `chats/<id>.api.json`, the raw messages array (Messages format with thinking blocks, or Chat Completions format, whichever the endpoint speaks), so a follow-up replays exactly. The chat records its endpoint id. Deleting a chat asks first and removes both files, and its saved images.
A chat is one file in the project: `chats/<id>.json`, holding the title, the backend (kind, model, effort, CLI or ACP session id, agent) and the messages with their tool calls, usage and run ids. The API backend also keeps `chats/<id>.api.json`, the raw messages array (Messages format with thinking blocks, or Chat Completions format, whichever the endpoint speaks), so a follow-up replays exactly. The chat records its endpoint id. Deleting a chat asks first and removes both files, and its saved images.
Projects without a `chats/` folder load unchanged. The newest chat opens when you enter Chat mode.
## Privacy
PaperCraft never writes an API key into a project file, a chat or a log. The key lives in the OS credential store. The CLIs run with your own logins and do not get the API keys from the environment: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` and `CODEX_API_KEY` are removed from the CLI's environment, so Claude Code and Codex bill your account, not a key you set for PaperCraft. The MCP server listens on loopback only, as it does for any other client.
PaperCraft never writes an API key into a project file, a chat or a log. The key lives in the OS credential store. The CLIs and ACP agents run with your own logins and do not get the API keys from the environment: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` and `CODEX_API_KEY` are removed from their environment, so Claude Code and Codex bill your account, not a key you set for PaperCraft. The MCP server listens on loopback only, as it does for any other client.
Prompts, project content and tool results go to whichever backend you chose: the CLI's model service, or the base URL of the endpoint you picked. Nothing goes to any other service. If a server echoes your key in an error message, the app hides it before showing the message.
Prompts, project content and tool results go to whichever backend you chose: the CLI's or agent's model service, or the base URL of the endpoint you picked. Nothing goes to any other service. An npx agent downloads its package from npm the first time it runs. If a server echoes your key in an error message, the app hides it before showing the message.
## Limits
@@ -89,4 +131,5 @@ Prompts, project content and tool results go to whichever backend you chose: the
* The canvas shows the run's outlines after the turn ends, when the run is reviewed. They are not drawn live while the agent works.
* The API backend stops after 40 tool rounds in one message and says so.
* Codex reports no per-call usage, so the context meter is hidden for it.
* The CLI backends need the CLI installed and logged in. Chat does not install or log in for you.
* Agents need to be installed and signed in with their own tools. Chat does not install them or sign them in for you.
* ACP terminal and file-system requests from an agent are answered as not supported. Agents use their own file tools.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 KiB

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 KiB

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+71
View File
@@ -0,0 +1,71 @@
# Annotations: notes with leader lines that explain a design
## Why (user request, 2026-10-21)
Teams annotate designs in Figma with callouts and arrows that explain what each part does: business rules, behaviour, states, data sources, edge cases. They are toggled on for review and handoff and off for presenting. PaperCraft has comments (threads, open/resolved) but nothing that documents a design in place.
## Model
* An **annotation** belongs to a page: `{ id, target: NodeId (or a point), anchor: side or offset on the target, label: short title, body: markdown text, category, number, position: where the note sits on the canvas, author: user | agent, created }`. Stored in the page file under `annotations` (serde default; old pages load unchanged).
* **Categories** are project-level, editable: default `Business rule`, `Behaviour`, `Content`, `Data`, `Accessibility`, `Dev note`, each with a colour (theme palette) and icon.
* Annotations survive moves and resizes of the target (anchor is relative); deleting the target keeps the annotation detached with a warning badge.
## Classification drives the look (user, 2026-10-21)
* The **category is the only styling input**. Icon, colour, badge shape and default label prefix come from the category; an annotation stores no icon or colour of its own. Changing a category's colour or icon restyles every note in it.
* Categories carry a short **type key** used everywhere (`rule`, `behaviour`, `content`, `data`, `a11y`, `dev`); custom categories get a key too.
* **Hiding information**, three levels, all undo-free view state (not saved in the design):
* per note: collapse to its number badge only (click the badge to expand);
* per category: show/hide from the category filter;
* global (⌘⇧A cycles; ⇧A is Add auto layout): full notes → badges only → hidden.
* Exports and Present mode use "hidden" unless chosen otherwise.
## Templates (user, 2026-10-21)
People annotate differently per project and even per page (a product spec, a dev handoff, an accessibility review). A **template** says which kinds of notes are wanted and what each should contain.
* `AnnotationTemplate { id, name, categories: [ { key, enabled, prompt: short guidance for what this note should say, fields: [name] (optional structured lines, e.g. "Applies when", "Owner") } ], display: full | badges }`.
* Stored in `design.json` (project-level list); the **project default** is one template, and a **page can override** it (`page.annotation_template`, serde default). New categories added in a template become available project-wide.
* **Built-in templates** (editable copies, not locked): `Product spec` (rule, behaviour, content, data), `Dev handoff` (behaviour, data, dev, a11y), `Accessibility review` (a11y with fields "Issue", "WCAG", "Fix"), `Content review` (content with "Tone", "Max length"), `Minimal` (rule only).
* **UI**: the Annotations side view header has a template picker (project default or this page) and a "Customize…" modal: toggle categories, edit name/colour/icon/key, edit each category's guidance and fields, duplicate/reset a template. The Annotate popover only offers the template's enabled categories, and shows the fields as labelled lines.
* **Ask AI**: an "Annotate with AI" button in the side view (and a chat suggestion "Annotate this page") sends a prompt to the chat: annotate the selected frame or the page using the active template. The agent reads the template with one cheap call.
* MCP: `get_annotation_template {pageId?}` returns compact `[[key, guidance, fields]]` for the enabled categories (a few hundred tokens); `annotate` rejects types the page's template disables (error lists the allowed keys) and accepts `fields` as `"Name: value"` lines inside `text`. `set_annotation_template {pageId?, template}` switches it.
## Cheap for agents
* One tool call can add many notes: `annotate {notes: [{node, text, type}]}` where `node` is a node id or a layer name on the active page, `text` is the note (first line becomes the label, the rest the body), `type` is the category key (default `rule`). No positions, icons or colours: placement is automatic (beside the target, outside its frame, stacked without overlap).
* `get_annotations` returns compact rows `[n, node, type, text]`, not full objects; `{full: true}` for everything.
* `update_annotation` / `delete_annotations` take ids or numbers ("frame Sign in #3").
* The tool description stays under ~60 words.
## Canvas
* A note is a small card (number badge, label, first lines of the body) connected to its target by a **leader line**: elbow or straight, ending in a dot or arrow on the target's anchor, plus a thin outline around the target while hovered.
* Notes are draggable; the line re-routes (reuse the connector router, `src/connector.rs`).
* **Toggle**: View → Annotations (⌘⇧A) shows or hides all; a filter per category; never drawn in Present mode or exports (notes live only in the editor overlay; an export option to include them is a later item).
* Numbering is per frame, in reading order, so a frame reads "1, 2, 3…".
## UI
* **Annotate tool** (N; A is the Frame tool): click a layer, type the note; or select layers → right-click → Annotate.
* **Annotations side view**: list per frame with number, category chip, label; click to select and zoom; filter by category; edit inline.
* **Inspector**: an Annotations section for the selected layer.
## Agents and handoff
* MCP: `annotate` (batch, see "Cheap for agents"), `get_annotations`, `update_annotation`, `delete_annotations`; classified in `edits_doc`.
* `get_design_spec` and `get_jsx` include annotations as comments (`// 1 Business rule: …`), so code generated from a frame carries the rules.
* Chat: the `@ Selection` context includes the selected layers' annotations; the agent can be asked to "annotate this screen".
## Mock-ups
`PaperCraft` design file, page `Annotations`: `Canvas / Annotated frame` (several categories, leader lines, numbering), `Canvas / Annotations hidden`, `Panel / Annotations` (344x1440), `Inspector / Annotation` (344x1440), `Popover / New annotation`.
## Acceptance
* Add, edit, move, hide/show, filter, delete annotations; undo for each.
* Moving or resizing a target keeps the leader line attached; deleting it detaches the note with a badge.
* Old projects load unchanged; page files without `annotations` stay byte-identical on save.
* Exports and Present mode never include annotations.
* MCP tools round trip; `get_design_spec` includes them.
* Screenshot check against the mock-ups; tests for model, anchoring, numbering, serialization, MCP.
+24
View File
@@ -0,0 +1,24 @@
# Tasks
Stop on failure. UI tasks go to Sonnet, never Haiku. Agents clean up after themselves.
## Wave 1 (with chat-agents wave 1)
- [x] 1. **Mock-ups** on page `Annotations`. Sonnet.
## Wave 2 (parallel with chat-agents wave 2)
- [x] 2. **Model and storage**: `Annotation`, categories in `design.json`, page field (serde default, byte-identical save for old pages), anchoring relative to target, numbering per frame in reading order, undoable edits through `Editor`. Tests. Sonnet.
- [x] 3. **MCP tools** `add_annotation`, `get_annotations`, `update_annotation`, `delete_annotations`; `get_design_spec` / `get_jsx` include them as comments; classified in `edits_doc`. Codex Sol (Sonnet if unavailable).
- [x] 3b. **Templates**: model in `design.json` + page override, built-ins, `get_annotation_template` / `set_annotation_template`, `annotate` validates against the page's template. Sonnet (after 3).
## Wave 3 (after wave 2 and the mock-ups)
- [x] 4. **Canvas**: note cards, leader lines (connector router), drag, toggle ⇧A, category filter, hidden in Present and exports unless asked. Sonnet.
- [x] 5. **Annotate tool, side view (template picker, Customize modal, Annotate with AI), inspector section, popover**. Sonnet.
## Wave 4
- [x] 6. **Review** (Codex Sol, read-only) and fixes (Sonnet); chat `@ Selection` context includes annotations.
- [x] 7. **Docs** (Haiku): `docs/annotations.md`, README, `docs/agents.md`, ROADMAP, PROGRESS.
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+38
View File
@@ -0,0 +1,38 @@
# Chat agents: any agent, found reliably, and a picker that scales
## Why (user report, 2026-10-21)
* Claude Code and Codex show "Not found on PATH" although both are installed in `~/.local/bin`. Root cause: an app opened from the Dock or Finder gets the minimal macOS PATH (`/usr/bin:/bin:/usr/sbin:/sbin`); `launchctl getenv PATH` is empty. The user's login shell also finds `pi`, `opencode`, `gemini`, `goose` (`/opt/homebrew/bin`) and `cursor-agent` (`~/.local/bin`).
* The setup state offers `npm install -g …` as the way to install agents. Claude Code and Codex have their own installers; PaperCraft should not tell people how to install agents at all.
* Only two agents are supported; the user wants every popular agent, generically.
* The agent picker popup has no shadow and overlaps the setup card; the Settings → Chat page has broken spacing, padding and height (fields not aligned, the page bottom cut off).
* Sending a message did nothing: no `chats/` folder was created, no error was shown.
* The composer allows one `@ Selection` chip; the user wants several.
## Scope
1. **Find agents like the shell does.** At startup (native, off the UI thread), resolve the login shell PATH once: `$SHELL -l -c 'printf %s "$PATH"'` with a 3 s timeout; merge with the process PATH and common folders (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `~/.cargo/bin`, `~/.bun/bin`, `~/.npm-global/bin`, Volta and nvm current bins). Use it for agent detection and for spawning agents (set `PATH` on the child). A path override in Settings still wins.
2. **ACP backend** (`src/chat/acp.rs`): a generic Agent Client Protocol client (JSON-RPC 2.0 over the agent's stdio): `initialize`, `session/new` with `cwd` = project folder and `mcpServers` = PaperCraft's server (HTTP entry when the agent advertises `mcpCapabilities.http`, else a stdio entry running the bundled `papercraft-mcp` bridge with `PAPERCRAFT_MCP_PORT`), `session/prompt` with text (and image) content blocks, streaming `session/update` notifications mapped to our `Event`s (agent message chunks → Text, thought chunks → Thinking, tool_call / tool_call_update → ToolStart/ToolEnd, plan → Plan items), `session/cancel` for Stop, `session/request_permission` answered from our approval flow (papercraft tools allowed; destructive ones and any non-papercraft tool ask the user), `session/load` to resume when supported. The PaperCraft skill text goes in as the first prompt's leading context (ACP has no system prompt field).
3. **Agent registry, as data** (`src/chat/agents.rs`): `{ id, name, detect: binary name, launch: command + args, kind: Native | Npx, docs_url }`. Native entries are found on the resolved PATH: Gemini CLI (`gemini --acp`), Cursor (`cursor-agent acp`), OpenCode (`opencode acp`), Goose (`goose acp`), Kimi (`kimi acp`), Devin (`devin acp`), Junie (`junie --acp=true`), Mistral Vibe (`vibe-acp`), Hermes (`hermes acp`), OpenHands (`openhands acp`), Stakpak (`stakpak acp`), VT Code (`vtcode acp`), Poolside (`pool acp`), Droid (`droid exec --output-format acp-daemon`). `npx`-launched entries (Qwen Code, Kilo, Cline, Auggie, Copilot CLI, Qoder, Codebuddy, Pi via `pi-acp`, Claude/Codex via their ACP adapters) are listed as available but off until the user enables them once, because enabling runs code fetched by `npx`. Claude Code and Codex keep their direct adapters (already proven) and are the default for them. Exact flags are verified against each installed agent's `--help` where installed; the rest keep the documented command and fail with the agent's own error.
4. **Custom agent**: name + command + args (ACP over stdio). No provider lists beyond the registry.
5. **No install instructions.** A missing agent shows "Not found" plus "Set path…" and a link to the agent's own docs (`docs_url`), nothing else.
6. **Agent picker becomes a panel**, not a dropdown: opened from the agent chip, sized like the chat column (overlay with shadow and a backdrop), sections Detected, Enable (npx), API endpoints, Custom, a search field, and per row: icon, name, status chip (Ready, Needs enable, Not found, Signed out if detectable), version, path. Clicking a row selects it for this chat.
7. **Settings → Chat** redesign with the same list (one row per agent with a details disclosure: path override, args override, enable), API endpoints as compact rows with an edit disclosure, consistent 16/24 px padding, labels aligned on one grid, the page scrolls instead of being cut off.
8. **Setup state** in the chat column: one card "Connect an agent" listing detected agents to pick from (usually already some), plus "Add an API endpoint" and "Add custom agent"; no install commands.
9. **Send bug**: reproduce, find the root cause, fix, and add a regression test; any start failure must show an error row in the thread (never a silent no-op) and keep the typed text.
10. **Multiple selections**: each `@` adds a chip (selection at that moment, labelled by layer names); chips stack, can be removed one by one; all are sent as context lines with node ids.
## Not in scope
* Installing agents.
* ACP terminal/file-system client methods beyond what PaperCraft needs (answer `fs/*` and `terminal/*` requests with "not supported" unless the agent requires them to work; most agents use their own file tools).
## Acceptance
* With the app launched from the Dock: Claude Code, Codex, Pi, OpenCode, Gemini, Goose and Cursor are detected on this machine.
* A turn with Gemini CLI, OpenCode, Goose and Cursor (each installed here) creates an artboard through PaperCraft's tools via ACP; a Pi turn works after enabling `pi-acp`.
* Stop cancels an ACP turn within 3 s.
* Picker panel, Settings → Chat and the setup state match new mock-ups at 1440p and 1280x800; nothing clipped; shadows on overlays.
* Sending a message always either starts a turn or shows an error row.
* Three `@` chips can be added and removed independently.
* Tests: PATH merge, registry detection with a fake PATH, ACP JSON-RPC handshake and update mapping against a fake agent process (recorded fixture), permission flow, cancel.
+23
View File
@@ -0,0 +1,23 @@
# Tasks
Stop on failure. UI tasks go to Sonnet (or Codex Sol), never Haiku. Each agent cleans its temp homes, logs and drivers when done; one shared `target/`.
## Wave 1 (parallel)
- [x] 1. **Mock-ups** on page `Chat agents` (agent panel, setup, Settings / Chat, multiple selections). Sonnet.
- [x] 2. **PATH resolution, no install hints, send bug, multiple `@` chips** (spec items 1, 5, 9, 10). Sonnet.
## Wave 2 (after wave 1)
- [x] 3. **ACP backend** `src/chat/acp.rs`: JSON-RPC client, session/new with PaperCraft's MCP server (HTTP or the `papercraft-mcp` stdio bridge), prompt, update mapping, permission flow, cancel, load. Fake-agent fixture tests; real turns with Gemini CLI, OpenCode, Goose and Cursor (installed here). Sonnet.
- [x] 4. **Agent registry and custom agents** `src/chat/agents.rs`: data table, detection on the resolved PATH, npx enable state, custom agent entries in ChatPrefs, BackendKind/Backend for ACP agents. Codex Sol (Sonnet if unavailable).
## Wave 3 (after wave 2 and the mock-ups)
- [x] 5. **Agent panel and setup state** in the chat column (spec items 6, 8). Sonnet.
- [x] 6. **Settings → Chat redesign** (spec item 7). Sonnet.
## Wave 4
- [x] 7. **Review** (Codex Sol, read-only), fixes (Sonnet).
- [x] 8. **Docs** `docs/chat.md` (agents, ACP, custom), README. Haiku.
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+2
View File
@@ -1558,6 +1558,8 @@ fn prefer_x11(kernel_release: &str, display: Option<&std::ffi::OsStr>) -> bool {
}
pub fn run(start: Start) -> Result<(), String> {
#[cfg(not(target_arch = "wasm32"))]
crate::chat::path::init();
let store = Store::open(Store::default_root())
.map_err(|e| format!("cannot create the projects folder: {e}"))?;
let mut builder = EventLoop::<UserEvent>::with_user_event();
+791
View File
@@ -0,0 +1,791 @@
//! A generic Agent Client Protocol client (https://agentclientprotocol.com): JSON-RPC 2.0, one
//! message per line, over the agent's stdio. One process per turn: `initialize`, `session/new`
//! (or `session/load`) with PaperCraft's MCP server, `session/prompt`, then the agent's
//! `session/update` notifications are mapped to [`Event`]s until the prompt response arrives.
use super::events::{Event, Turn, TurnConfig};
use super::{tools, Usage};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
const PROTOCOL_VERSION: u64 = 1;
const STOP_TIMEOUT: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(20);
const TICK: Duration = Duration::from_millis(50);
/// Agents may start slowly (node, login); a prompt itself has no limit.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(120);
/// After the turn the agent gets this long to exit on its own (flush its session), then dies.
const EXIT_GRACE: Duration = Duration::from_secs(1);
const STDERR_MAX: usize = 16 * 1024;
const METHOD_NOT_FOUND: i64 = -32601;
/// The Plan card is the existing `TodoWrite` card.
const PLAN_TOOL: &str = "TodoWrite";
const SERVER_NAME: &str = "papercraft";
const BRIDGE: &str = "papercraft-mcp";
/// How agents prefix MCP tool names; longest first.
const NAME_PREFIXES: [&str; 4] = ["mcp__papercraft__", "mcp_papercraft_", "papercraft__", "papercraft_"];
/// Same keys the other backends drop: the agent must run on its own login.
const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
type Stdin = Arc<Mutex<Option<ChildStdin>>>;
fn send(stdin: &Stdin, v: &Value) -> bool {
let mut g = stdin.lock().unwrap();
let Some(w) = g.as_mut() else { return false };
writeln!(w, "{v}").and_then(|_| w.flush()).is_ok()
}
/// SIGKILL to the agent's whole process group (it is its own group), so its helpers die too.
fn kill_tree(child: &mut Child) {
#[cfg(unix)]
unsafe {
libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
}
let _ = child.kill();
let _ = child.wait();
}
pub struct AcpTurn {
child: Arc<Mutex<Child>>,
stdin: Stdin,
session: Arc<Mutex<Option<String>>>,
running: Arc<AtomicBool>,
stopped: Arc<AtomicBool>,
approvals: Sender<(String, bool)>,
}
impl AcpTurn {
/// Answers a `NeedsApproval` event (`id` = the event's id).
pub fn approve(&self, id: &str, ok: bool) {
let _ = self.approvals.send((id.to_string(), ok));
}
}
impl Turn for AcpTurn {
/// Never blocks: the cancel write and the kill timer run on a thread.
fn stop(&mut self) {
if self.stopped.swap(true, Ordering::SeqCst) {
return;
}
let (child, stdin, session) = (self.child.clone(), self.stdin.clone(), self.session.clone());
// The kill timer starts first: a stalled agent can block the cancel write forever.
let deadline = Instant::now() + STOP_TIMEOUT;
thread::spawn(move || loop {
let mut c = child.lock().unwrap();
if !matches!(c.try_wait(), Ok(None)) {
return;
}
if Instant::now() >= deadline {
kill_tree(&mut c);
return;
}
drop(c);
thread::sleep(POLL);
});
thread::spawn(move || {
let sid = session.lock().unwrap().clone();
if let Some(sid) = sid {
send(&stdin, &json!({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": sid}}));
}
});
}
fn is_running(&self) -> bool {
self.running.load(Ordering::Acquire)
}
}
/// Starts `program args…` and runs one prompt. Events arrive on `tx`; the last is `TurnEnd` or
/// `Error`.
pub fn start(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Sender<Event>) -> Result<AcpTurn, String> {
spawn(cfg, program, args, prompt, tx, None)
}
fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Sender<Event>, bridge: Option<PathBuf>) -> Result<AcpTurn, String> {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(&cfg.project_dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for k in CREDENTIAL_ENV {
cmd.env_remove(k);
}
super::path::apply(&mut cmd);
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
let mut child = cmd.spawn().map_err(|e| format!("Could not start {}: {e}", program.display()))?;
let (stdout, mut stderr) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
let stdin: Stdin = Arc::new(Mutex::new(child.stdin.take()));
let child = Arc::new(Mutex::new(child));
let (approvals, approvals_rx) = channel();
let turn = AcpTurn {
child: child.clone(),
stdin: stdin.clone(),
session: Arc::new(Mutex::new(None)),
running: Arc::new(AtomicBool::new(true)),
stopped: Arc::new(AtomicBool::new(false)),
approvals,
};
let (session, running, stopped) = (turn.session.clone(), turn.running.clone(), turn.stopped.clone());
let (line_tx, rx) = channel();
thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
match serde_json::from_str::<Value>(&line) {
Ok(v) if v.is_object() => {
if line_tx.send(v).is_err() {
return;
}
}
_ => log::debug!("acp: skipped non-JSON line: {line}"),
}
}
});
let err_text = Arc::new(Mutex::new(String::new()));
let err_sink = err_text.clone();
thread::spawn(move || {
let mut buf = [0u8; 1024];
while let Ok(n @ 1..) = stderr.read(&mut buf) {
let mut t = err_sink.lock().unwrap();
if t.len() < STDERR_MAX {
t.push_str(&String::from_utf8_lossy(&buf[..n]));
}
}
});
let (cfg, prompt, name) = (cfg.clone(), prompt.to_string(), program.display().to_string());
thread::spawn(move || {
let mut c = Client {
rx,
stdin: stdin.clone(),
approvals: approvals_rx,
stopped: stopped.clone(),
tx: tx.clone(),
next_id: 0,
replay: false,
pending: HashMap::new(),
tools: HashMap::new(),
usage: None,
cost: None,
plans: 0,
sid: None,
};
let result = c.run(&cfg, &prompt, bridge, &session);
c.close_open_tools();
let end = match result {
Ok(stop) => Event::TurnEnd {
usage: c.usage.take(),
session: c.sid.clone(),
stop_reason: Some(if stop == "cancelled" { "stopped".into() } else { stop }),
},
Err(_) if stopped.load(Ordering::SeqCst) => Event::TurnEnd { usage: None, session: c.sid.clone(), stop_reason: Some("stopped".into()) },
Err(e) => {
// Give a dying agent a moment to flush its error text.
thread::sleep(POLL * 5);
let err = err_text.lock().unwrap().trim().to_string();
Event::Error(if err.is_empty() { format!("{name}: {e}") } else { format!("{name}: {e}: {}", tail(&err)) })
}
};
// Send first: the app treats "not running" as "everything is in the channel".
let _ = tx.send(end);
stdin.lock().unwrap().take(); // EOF: a well-behaved agent exits
let deadline = Instant::now() + EXIT_GRACE;
loop {
let mut ch = child.lock().unwrap();
if !matches!(ch.try_wait(), Ok(None)) {
break;
}
if Instant::now() >= deadline {
kill_tree(&mut ch);
break;
}
drop(ch);
thread::sleep(POLL);
}
running.store(false, Ordering::Release);
});
Ok(turn)
}
fn tail(s: &str) -> &str {
const KEEP: usize = 600;
let mut from = s.len().saturating_sub(KEEP);
while !s.is_char_boundary(from) {
from += 1;
}
&s[from..]
}
struct Tool {
name: String,
args: Value,
ended: bool,
/// Announced as one of PaperCraft's own MCP tools (never a shell, edit or fetch).
ours: bool,
}
struct Client {
rx: Receiver<Value>,
stdin: Stdin,
approvals: Receiver<(String, bool)>,
stopped: Arc<AtomicBool>,
tx: Sender<Event>,
next_id: u64,
/// `session/load` replays the history as updates; those are not new output.
replay: bool,
/// Permission requests waiting for the user: tool call id -> (JSON-RPC id, options).
pending: HashMap<String, (Value, Vec<Value>)>,
tools: HashMap<String, Tool>,
usage: Option<Usage>,
cost: Option<f64>,
plans: u32,
sid: Option<String>,
}
impl Client {
fn emit(&self, e: Event) {
let _ = self.tx.send(e);
}
fn run(&mut self, cfg: &TurnConfig, prompt: &str, bridge: Option<PathBuf>, slot: &Mutex<Option<String>>) -> Result<String, String> {
let init = self.call(
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false},
"clientInfo": {"name": "papercraft", "title": "PaperCraft", "version": env!("CARGO_PKG_VERSION")},
}),
Some(HANDSHAKE_TIMEOUT),
)?;
let caps = &init["agentCapabilities"];
let http = caps["mcpCapabilities"]["http"] == true;
let image = caps["promptCapabilities"]["image"] == true;
let bridge = if http { None } else { bridge.or_else(find_bridge) };
let session_params = |extra: Value| -> Result<Value, String> {
let mut p = json!({"cwd": cfg.project_dir, "mcpServers": mcp_servers(&cfg.mcp_url, http, bridge.as_deref())?});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
Ok(p)
};
let mut loaded = None;
if let Some(id) = cfg.session.as_ref().filter(|_| caps["loadSession"] == true) {
self.replay = true;
let r = self.call("session/load", session_params(json!({"sessionId": id}))?, Some(HANDSHAKE_TIMEOUT));
self.replay = false;
match r {
Ok(_) => loaded = Some(id.clone()),
Err(e) => log::warn!("acp: session/load failed ({e}); starting a new session"),
}
}
let first = loaded.is_none();
let sid = match loaded {
Some(id) => id,
None => {
let r = self.call("session/new", session_params(json!({}))?, Some(HANDSHAKE_TIMEOUT))?;
r["sessionId"].as_str().ok_or("session/new returned no sessionId")?.to_string()
}
};
self.sid = Some(sid.clone());
*slot.lock().unwrap() = Some(sid.clone());
self.emit(Event::Session(sid.clone()));
// ACP has no system prompt: the PaperCraft text leads the first prompt of a session.
let mut blocks = vec![];
if first && !cfg.system.is_empty() {
blocks.push(json!({"type": "text", "text": cfg.system}));
}
blocks.push(json!({"type": "text", "text": prompt}));
if image {
blocks.extend(cfg.images.iter().map(|i| json!({"type": "image", "mimeType": i.mime, "data": STANDARD.encode(&i.data)})));
}
let r = self.call("session/prompt", json!({"sessionId": sid, "prompt": blocks}), None)?;
self.usage = usage_of(&r["usage"]).map(|mut u| {
u.cost_usd = self.cost;
u
});
if self.usage.is_none() && self.cost.is_some() {
self.usage = Some(Usage { cost_usd: self.cost, ..Default::default() });
}
Ok(r["stopReason"].as_str().unwrap_or("end_turn").to_string())
}
/// Sends a request and serves the agent's messages until its response arrives.
fn call(&mut self, method: &str, params: Value, timeout: Option<Duration>) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
if !send(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})) {
return Err("the agent closed its input".into());
}
let deadline = timeout.map(|t| Instant::now() + t);
loop {
while let Ok((tool, ok)) = self.approvals.try_recv() {
if let Some((rpc, options)) = self.pending.remove(&tool) {
self.answer(&rpc, &options, ok);
}
}
if self.stopped.load(Ordering::SeqCst) {
// The spec: every pending permission request gets the `cancelled` outcome.
for (_, (rpc, _)) in self.pending.drain() {
send(&self.stdin, &reply(&rpc, json!({"outcome": {"outcome": "cancelled"}})));
}
}
match self.rx.recv_timeout(TICK) {
Ok(msg) if msg.get("method").is_some() => self.incoming(&msg),
Ok(msg) if msg["id"] == json!(id) => {
return match msg.get("error") {
Some(e) => Err(rpc_error(e)),
None => Ok(msg["result"].clone()),
};
}
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => {
if deadline.is_some_and(|d| Instant::now() >= d) {
return Err(format!("{method} timed out"));
}
}
Err(RecvTimeoutError::Disconnected) => return Err("the agent exited".into()),
}
}
}
fn incoming(&mut self, msg: &Value) {
let method = msg["method"].as_str().unwrap_or_default();
match (method, msg.get("id")) {
("session/update", _) => self.update(&msg["params"]["update"]),
("session/request_permission", Some(id)) => self.permission(id.clone(), &msg["params"]),
(m, Some(id)) => {
log::debug!("acp: unsupported client method {m}");
send(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "error": {"code": METHOD_NOT_FOUND, "message": format!("method not found: {m}")}}));
}
(m, None) => log::debug!("acp: ignored notification {m}"),
}
}
fn update(&mut self, u: &Value) {
if self.replay {
return;
}
let text = |v: &Value| v["content"]["text"].as_str().filter(|t| !t.is_empty()).map(String::from);
match u["sessionUpdate"].as_str().unwrap_or_default() {
"agent_message_chunk" => text(u).into_iter().for_each(|t| self.emit(Event::Text(t))),
"agent_thought_chunk" => text(u).into_iter().for_each(|t| self.emit(Event::Thinking(t))),
"tool_call" => self.tool_call(u),
"tool_call_update" => {
if !self.tools.contains_key(&call_id(u)) {
self.tool_call(u);
} else {
self.tool_status(u);
}
}
"plan" => self.plan(u),
"usage_update" => {
if let Some(used) = u["used"].as_u64() {
self.emit(Event::Context(used));
}
if u["cost"]["currency"] == "USD" {
self.cost = u["cost"]["amount"].as_f64();
}
}
other => log::debug!("acp: skipped session update {other:?}"),
}
}
fn tool_call(&mut self, u: &Value) {
let id = call_id(u);
if id.is_empty() {
return;
}
let args = if u["rawInput"].is_null() { json!({}) } else { u["rawInput"].clone() };
let name = canonical_name(&tool_name(u));
let ours = is_ours(&name, u["kind"].as_str());
self.tools.insert(id.clone(), Tool { name: name.clone(), args: args.clone(), ended: false, ours });
self.emit(Event::ToolStart { id, name, args });
self.tool_status(u);
}
fn tool_status(&mut self, u: &Value) {
let id = call_id(u);
let Some(t) = self.tools.get_mut(&id) else { return };
if t.ended || !["completed", "failed"].contains(&u["status"].as_str().unwrap_or_default()) {
return;
}
t.ended = true;
let text = content_text(&u["content"]);
let ev = if u["status"] == "failed" {
let msg = if !text.is_empty() { text } else if u["rawOutput"].is_null() { "tool failed".into() } else { u["rawOutput"].to_string() };
Event::ToolEnd { id, result: None, error: Some(msg) }
} else {
let result = if !text.is_empty() { json!(text) } else if u["rawOutput"].is_null() { json!("") } else { u["rawOutput"].clone() };
Event::ToolEnd { id, result: Some(result), error: None }
};
self.emit(ev);
}
/// A stopped turn leaves tools open; close them so no card spins forever.
fn close_open_tools(&mut self) {
let open: Vec<String> = self.tools.iter().filter(|(_, t)| !t.ended).map(|(id, _)| id.clone()).collect();
for id in open {
if let Some(t) = self.tools.get_mut(&id) {
t.ended = true;
}
self.emit(Event::ToolEnd { id, result: None, error: Some(tools::STOPPED_BY_USER.into()) });
}
}
fn plan(&mut self, u: &Value) {
let Some(entries) = u["entries"].as_array() else { return };
let todos: Vec<Value> = entries
.iter()
.filter_map(|e| {
let status = match e["status"].as_str() {
Some(s @ ("in_progress" | "completed")) => s,
_ => "pending",
};
Some(json!({"content": e["content"].as_str()?, "status": status}))
})
.collect();
self.plans += 1;
let id = format!("acp-plan-{}", self.plans);
self.emit(Event::ToolStart { id: id.clone(), name: PLAN_TOOL.into(), args: json!({"todos": todos}) });
self.emit(Event::ToolEnd { id, result: Some(json!("ok")), error: None });
}
fn permission(&mut self, rpc: Value, params: &Value) {
let options = params["options"].as_array().cloned().unwrap_or_default();
let tc = &params["toolCall"];
let id = call_id(tc);
let known = self.tools.get(&id);
let name = known.map_or_else(|| canonical_name(&tool_name(tc)), |t| t.name.clone());
let args = if !tc["rawInput"].is_null() { tc["rawInput"].clone() } else { known.map_or(json!({}), |t| t.args.clone()) };
if known.is_some_and(|t| t.ours) && is_ours(&name, tc["kind"].as_str()) && !tools::needs_approval(&name, &args) {
return self.answer(&rpc, &options, true);
}
self.pending.insert(id.clone(), (rpc, options));
self.emit(Event::NeedsApproval { id, name, args });
}
fn answer(&self, rpc: &Value, options: &[Value], allow: bool) {
let prefix = if allow { "allow" } else { "reject" };
let pick = ["_once", "_always"]
.iter()
.find_map(|s| options.iter().find(|o| o["kind"] == format!("{prefix}{s}")))
.and_then(|o| o["optionId"].as_str());
let outcome = match pick {
Some(id) => json!({"outcome": "selected", "optionId": id}),
None => json!({"outcome": "cancelled"}),
};
send(&self.stdin, &reply(rpc, json!({"outcome": outcome})));
}
}
fn reply(id: &Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn rpc_error(e: &Value) -> String {
let mut m = e["message"].as_str().unwrap_or("request failed").to_string();
if let Some(d) = e["data"].as_str().or_else(|| e["data"]["details"].as_str()) {
m = format!("{m} ({d})");
}
m
}
fn call_id(u: &Value) -> String {
u["toolCallId"].as_str().unwrap_or_default().to_string()
}
/// `name` when the agent sends one, else the human `title`.
fn tool_name(u: &Value) -> String {
u["name"].as_str().or_else(|| u["title"].as_str()).unwrap_or("tool").to_string()
}
/// Name without the agent's MCP prefix or a trailing ` (papercraft MCP Server)` suffix.
fn canonical_name(name: &str) -> String {
let name = name.trim();
let name = match name.rfind(" (") {
Some(i) if name.ends_with(')') && name.to_ascii_lowercase()[i..].contains("mcp server") => &name[..i],
_ => name,
};
NAME_PREFIXES.iter().find_map(|p| name.strip_prefix(p)).unwrap_or(name).trim().to_string()
}
/// A tool of PaperCraft's own MCP server: the canonical name is one of our tools and the kind is
/// not a built-in agent kind (execute, edit, fetch, delete...).
fn is_ours(canonical: &str, kind: Option<&str>) -> bool {
matches!(kind, None | Some("other")) && tools::tool_specs().iter().any(|t| t["name"] == canonical)
}
/// Text of a `ToolCallContent` array: `{type:"content", content:{type:"text", text}}` entries.
fn content_text(v: &Value) -> String {
v.as_array()
.map(|a| a.iter().filter_map(|c| c["content"]["text"].as_str()).collect::<Vec<_>>().join("\n"))
.unwrap_or_default()
}
/// `{inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens}` of a prompt response.
fn usage_of(u: &Value) -> Option<Usage> {
if !u.is_object() {
return None;
}
let get = |k: &str| u[k].as_u64().unwrap_or(0);
Some(Usage { input: get("inputTokens"), output: get("outputTokens"), cache_read: get("cachedReadTokens"), cache_write: get("cachedWriteTokens"), cost_usd: None })
}
/// PaperCraft's MCP server as an ACP `mcpServers` list: HTTP when the agent supports it, else
/// the stdio `papercraft-mcp` bridge aimed at the same port.
fn mcp_servers(url: &str, http: bool, bridge: Option<&Path>) -> Result<Value, String> {
if http {
return Ok(json!([{"type": "http", "name": SERVER_NAME, "url": url, "headers": []}]));
}
let bridge = bridge.ok_or("This agent cannot use HTTP MCP servers and the stdio bridge (papercraft-mcp) was not found")?;
let port = url.rsplit_once(':').map(|(_, r)| r.split('/').next().unwrap_or_default()).filter(|p| p.parse::<u16>().is_ok());
let env: Vec<Value> = port.into_iter().map(|p| json!({"name": "PAPERCRAFT_MCP_PORT", "value": p})).collect();
Ok(json!([{"name": SERVER_NAME, "command": bridge, "args": [], "env": env}]))
}
/// `papercraft-mcp` next to the running executable, else on PATH.
fn find_bridge() -> Option<PathBuf> {
let file = format!("{BRIDGE}{}", std::env::consts::EXE_SUFFIX);
std::env::current_exe().ok().and_then(|e| e.parent().map(|d| d.join(&file))).filter(|p| p.is_file()).or_else(|| super::path::which(BRIDGE))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
const AGENT: &str = include_str!("../../tests/fixtures/chat/acp_fake_agent.sh");
const HTTP: &str = include_str!("../../tests/fixtures/chat/acp_http.jsonl");
const STDIO: &str = include_str!("../../tests/fixtures/chat/acp_stdio.jsonl");
const PERMISSION: &str = include_str!("../../tests/fixtures/chat/acp_permission.jsonl");
const CANCEL: &str = include_str!("../../tests/fixtures/chat/acp_cancel.jsonl");
const LOAD: &str = include_str!("../../tests/fixtures/chat/acp_load.jsonl");
const LOAD_FAILS: &str = include_str!("../../tests/fixtures/chat/acp_load_fails.jsonl");
const ERROR: &str = include_str!("../../tests/fixtures/chat/acp_error.jsonl");
fn cfg() -> TurnConfig {
TurnConfig { project_dir: std::env::temp_dir(), mcp_url: "http://127.0.0.1:29991/mcp".into(), system: "SYS".into(), ..Default::default() }
}
/// Runs the fake agent on `fixture`; `on` sees every event and may approve or stop. Returns
/// the events and the JSON lines the client wrote.
fn run(fixture: &str, cfg: TurnConfig, bridge: Option<PathBuf>, mut on: impl FnMut(&mut AcpTurn, &Event)) -> (Vec<Event>, Vec<Value>) {
let dir = std::env::temp_dir().join(format!("pc-acp-{}", fastrand::u64(..)));
std::fs::create_dir_all(&dir).unwrap();
let (script, fx, log) = (dir.join("agent.sh"), dir.join("fixture.jsonl"), dir.join("client.log"));
std::fs::write(&script, AGENT).unwrap();
std::fs::write(&fx, fixture).unwrap();
let args: Vec<String> = [&script, &fx, &log].iter().map(|p| p.display().to_string()).collect();
let (tx, rx) = channel();
let mut turn = spawn(&cfg, Path::new("/bin/sh"), &args, "hello", tx, bridge).unwrap();
let mut events = vec![];
loop {
let e = rx.recv_timeout(Duration::from_secs(15)).expect("turn timed out");
on(&mut turn, &e);
let done = matches!(e, Event::TurnEnd { .. } | Event::Error(_));
events.push(e);
if done {
break;
}
}
let t = Instant::now();
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
thread::sleep(POLL);
}
assert!(!turn.is_running());
let sent = std::fs::read_to_string(&log).unwrap_or_default().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
std::fs::remove_dir_all(&dir).unwrap();
(events, sent)
}
fn method<'a>(sent: &'a [Value], m: &str) -> &'a Value {
sent.iter().find(|v| v["method"] == m).unwrap_or_else(|| panic!("no {m} in {sent:?}"))
}
#[test]
fn http_handshake_streaming_tools_plan() {
let mut c = cfg();
c.images = vec![super::super::events::ImageAttachment { mime: "image/png".into(), data: vec![1, 2, 3] }];
let (ev, sent) = run(HTTP, c, None, |_, _| {});
let init = &method(&sent, "initialize")["params"];
assert_eq!(init["protocolVersion"], 1);
assert_eq!(init["clientCapabilities"], json!({"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false}));
let new = &method(&sent, "session/new")["params"];
assert_eq!(new["mcpServers"], json!([{"type": "http", "name": "papercraft", "url": "http://127.0.0.1:29991/mcp", "headers": []}]));
assert_eq!(new["cwd"], json!(std::env::temp_dir()));
let prompt = &method(&sent, "session/prompt")["params"];
assert_eq!(prompt["sessionId"], "sess-1");
assert_eq!(
prompt["prompt"],
json!([{"type": "text", "text": "SYS"}, {"type": "text", "text": "hello"}, {"type": "image", "mimeType": "image/png", "data": "AQID"}])
);
// unknown client request answered with -32601
assert_eq!(sent.iter().find(|v| v["id"] == 50).unwrap()["error"]["code"], METHOD_NOT_FOUND);
assert_eq!(ev[0], Event::Session("sess-1".into()));
assert_eq!(ev[1], Event::Thinking("hmm".into()));
assert_eq!(ev[2], Event::Text("Hel".into()));
assert_eq!(ev[3], Event::Text("lo".into()));
assert_eq!(ev[4], Event::ToolStart { id: "t1".into(), name: "get_basic_info".into(), args: json!({}) });
assert_eq!(ev[5], Event::ToolEnd { id: "t1".into(), result: Some(json!("{\"ok\":true}")), error: None });
assert_eq!(ev[6], Event::ToolStart { id: "t2".into(), name: "ls".into(), args: json!({"path": "."}) });
assert_eq!(ev[7], Event::ToolEnd { id: "t2".into(), result: None, error: Some("denied".into()) });
assert_eq!(ev[8], Event::ToolStart { id: "acp-plan-1".into(), name: "TodoWrite".into(), args: json!({"todos": [{"content": "A", "status": "completed"}, {"content": "B", "status": "pending"}]}) });
assert_eq!(ev[9], Event::ToolEnd { id: "acp-plan-1".into(), result: Some(json!("ok")), error: None });
assert_eq!(ev[10], Event::Context(5000));
let Event::TurnEnd { usage: Some(u), session: Some(s), stop_reason: Some(r) } = &ev[11] else { panic!("{:?}", ev[11]) };
assert_eq!((u.input, u.output, u.cost_usd, s.as_str(), r.as_str()), (7, 3, Some(0.5), "sess-1", "end_turn"));
assert_eq!(ev.len(), 12);
}
#[test]
fn stdio_bridge_when_no_http() {
let (ev, sent) = run(STDIO, cfg(), Some("/fake/papercraft-mcp".into()), |_, _| {});
assert_eq!(
method(&sent, "session/new")["params"]["mcpServers"],
json!([{"name": "papercraft", "command": "/fake/papercraft-mcp", "args": [], "env": [{"name": "PAPERCRAFT_MCP_PORT", "value": "29991"}]}])
);
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"].as_array().unwrap().len(), 2);
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "max_tokens"));
}
#[test]
fn permissions_auto_and_asked() {
let (ev, sent) = run(PERMISSION, cfg(), None, |turn, e| {
if let Event::NeedsApproval { id, .. } = e {
turn.approve(id, id == "t-del");
}
});
let asked: Vec<_> = ev.iter().filter_map(|e| if let Event::NeedsApproval { id, name, .. } = e { Some((id.as_str(), name.as_str())) } else { None }).collect();
assert_eq!(asked, [("t-del", "delete_pages"), ("t-sh", "Run ls")]);
let answer = |rpc: u64| sent.iter().find(|v| v["id"] == rpc).unwrap()["result"]["outcome"].clone();
assert_eq!(answer(100), json!({"outcome": "selected", "optionId": "allow-once"}));
assert_eq!(answer(101), json!({"outcome": "selected", "optionId": "allow-once"}));
assert_eq!(answer(102), json!({"outcome": "selected", "optionId": "reject-once"}));
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })));
}
#[test]
fn cancel_sends_notification_and_reports_stopped() {
let t = Instant::now();
let (ev, sent) = run(CANCEL, cfg(), None, |turn, e| {
if matches!(e, Event::Text(_)) {
turn.stop();
}
});
assert_eq!(method(&sent, "session/cancel")["params"]["sessionId"], "sess-1");
assert!(sent.iter().find(|v| v["method"] == "session/cancel").unwrap().get("id").is_none());
// the open tool is closed, the pending permission got `cancelled`
assert!(ev.iter().any(|e| matches!(e, Event::ToolEnd { id, error: Some(_), .. } if id == "t1")));
assert_eq!(sent.iter().find(|v| v["id"] == 100).unwrap()["result"]["outcome"], json!({"outcome": "cancelled"}));
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
assert!(t.elapsed() < Duration::from_secs(3));
}
#[test]
fn stop_kills_an_agent_that_ignores_cancel() {
let (tx, rx) = channel();
let args = ["-c".to_string(), "trap '' INT TERM; while :; do sleep 1; done".to_string()];
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, "x", tx, None).unwrap();
thread::sleep(Duration::from_millis(100));
let t = Instant::now();
turn.stop();
assert!(t.elapsed() < Duration::from_millis(200), "stop blocked");
assert!(matches!(rx.recv_timeout(Duration::from_secs(8)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
let t = Instant::now();
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
thread::sleep(POLL);
}
assert!(!turn.is_running());
}
#[test]
fn load_replays_silently_and_skips_the_system_text() {
let mut c = cfg();
c.session = Some("old".into());
let (ev, sent) = run(LOAD, c, None, |_, _| {});
let load = &method(&sent, "session/load")["params"];
assert_eq!(load["sessionId"], "old");
assert!(load["mcpServers"].is_array());
assert!(sent.iter().all(|v| v["method"] != "session/new"));
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"], json!([{"type": "text", "text": "hello"}]));
assert_eq!(ev[0], Event::Session("old".into()));
assert_eq!(ev[1], Event::Text("fresh".into()), "replayed history must not surface: {ev:?}");
assert!(matches!(&ev[2], Event::TurnEnd { session: Some(s), .. } if s == "old"));
}
#[test]
fn failed_load_falls_back_to_new_session() {
let mut c = cfg();
c.session = Some("gone".into());
let (ev, sent) = run(LOAD_FAILS, c, None, |_, _| {});
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"][0]["text"], "SYS");
assert_eq!(ev[0], Event::Session("sess-new".into()));
}
#[test]
fn rpc_error_becomes_error_event() {
let (ev, _) = run(ERROR, cfg(), None, |_, _| {});
assert!(matches!(ev.last(), Some(Event::Error(e)) if e.contains("Authentication required")), "{ev:?}");
}
#[test]
fn missing_program_is_an_error() {
let (tx, _rx) = channel();
assert!(spawn(&cfg(), Path::new("/nonexistent/agent"), &[], "x", tx, None).is_err());
}
#[test]
fn tool_names_and_papercraft_detection() {
assert_eq!(canonical_name("mcp__papercraft__get_basic_info"), "get_basic_info");
assert_eq!(canonical_name("papercraft_x"), "x");
assert_eq!(canonical_name("Read file"), "Read file");
assert_eq!(canonical_name("delete_pages (papercraft MCP Server)"), "delete_pages");
assert!(is_ours("get_basic_info", Some("other")));
assert!(!is_ours("get_basic_info", Some("execute")));
assert!(!is_ours("rm -rf papercraft", None));
assert!(!is_ours("bogus", None));
assert_eq!(mcp_servers("http://x:1/mcp", false, None).unwrap_err().contains("papercraft-mcp"), true);
}
#[test]
fn permission_trust_needs_an_announced_papercraft_tool() {
// title-only differences, unannounced ids, execute kind and suffixed destructive names all ask
let fx = [
r#"{"sessionUpdate":"tool_call","toolCallId":"a","title":"get_basic_info (papercraft MCP Server)","kind":"other","rawInput":{}}"#,
r#"{"sessionUpdate":"tool_call","toolCallId":"b","title":"Run ls","kind":"execute","rawInput":{}}"#,
r#"{"sessionUpdate":"tool_call","toolCallId":"d","title":"delete_pages (papercraft MCP Server)","kind":"other","rawInput":{}}"#,
];
let (tx, rx) = channel();
let (stdin, _) = (Arc::new(Mutex::new(None)), ());
let mut c = Client { rx: channel().1, stdin, approvals: channel().1, stopped: Arc::new(AtomicBool::new(false)), tx, next_id: 0, replay: false, pending: HashMap::new(), tools: HashMap::new(), usage: None, cost: None, plans: 0, sid: None };
for u in fx {
c.update(&serde_json::from_str(u).unwrap());
}
for (id, title, asked) in [("a", "get_basic_info (papercraft MCP Server)", false), ("b", "Run ls", true), ("d", "delete_pages (papercraft MCP Server)", true), ("zz", "get_basic_info", true), ("b", "get_basic_info", true)] {
c.pending.clear();
c.permission(json!(1), &json!({"toolCall": {"toolCallId": id, "title": title}, "options": []}));
assert_eq!(c.pending.contains_key(id), asked, "{id} {title}");
}
drop(rx);
}
#[test]
fn stop_kills_even_when_the_cancel_write_blocks() {
let (tx, rx) = channel();
// never reads stdin; a big write fills the pipe and blocks
let args = ["-c".to_string(), "trap '' INT TERM; while :; do sleep 1; done".to_string()];
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, "x", tx, None).unwrap();
*turn.session.lock().unwrap() = Some("s".repeat(1 << 20));
thread::sleep(Duration::from_millis(100));
turn.stop();
assert!(matches!(rx.recv_timeout(Duration::from_secs(8)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
}
}
+365
View File
@@ -0,0 +1,365 @@
//! The agent registry: every coding agent PaperCraft knows how to start, as data, plus detection
//! on the resolved PATH and the command that launches one over ACP.
//!
//! Launch args were checked against the agents installed on the dev machine (2026-10-21):
//! * Gemini CLI 0.44.1: `gemini --acp` (`--help` lists `--acp`; `--experimental-acp` is deprecated).
//! * OpenCode 1.18.30: `opencode acp` ("start ACP (Agent Client Protocol) server").
//! * Cursor Agent 2026.03.20: the binary is `cursor-agent` (its help says `agent`); `cursor-agent acp`
//! ("Start the Cursor Agent as an ACP server").
//! * Pi 0.80.3 has no ACP mode of its own; the `pi-acp` adapter is run with `npx`.
//! * Goose: `/opt/homebrew/bin/goose` here is pressly/goose, the SQL migration tool. Block's agent
//! has an `acp` subcommand; the migration tool's `--help` never says `acp`, so that is the identity.
//! The rest keep their documented command and fail with the agent's own error.
#[cfg(not(target_arch = "wasm32"))]
use crate::ui::settings::ChatPrefs;
#[cfg(not(target_arch = "wasm32"))]
use crate::ui::settings::CustomAgent;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentKind {
/// A program found on PATH that speaks ACP.
Native,
/// Run with `npx -y <package>`; off until the user enables it, because that runs fetched code.
Npx { package: &'static str },
/// Claude Code and Codex: proven direct backends, not started through ACP.
Direct,
}
#[derive(Debug, Clone, Copy)]
pub struct AgentDef {
pub id: &'static str,
pub name: &'static str,
pub binary: &'static str,
/// Args after the binary (or after the package for `Npx`) that start ACP mode.
pub launch: &'static [&'static str],
pub kind: AgentKind,
pub docs_url: &'static str,
/// Text (case-insensitive) that `--version` or `--help` of the real program contains.
pub identity: Option<&'static str>,
}
const fn native(id: &'static str, name: &'static str, binary: &'static str, launch: &'static [&'static str], docs_url: &'static str, identity: Option<&'static str>) -> AgentDef {
AgentDef { id, name, binary, launch, kind: AgentKind::Native, docs_url, identity }
}
const fn npx(id: &'static str, name: &'static str, package: &'static str, launch: &'static [&'static str], docs_url: &'static str) -> AgentDef {
AgentDef { id, name, binary: "npx", launch, kind: AgentKind::Npx { package }, docs_url, identity: None }
}
pub const AGENTS: &[AgentDef] = &[
AgentDef { id: "claude-code", name: "Claude Code", binary: "claude", launch: &[], kind: AgentKind::Direct, docs_url: super::CLAUDE_DOCS, identity: Some("claude code") },
AgentDef { id: "codex", name: "Codex", binary: "codex", launch: &[], kind: AgentKind::Direct, docs_url: super::CODEX_DOCS, identity: Some("codex") },
native("gemini", "Gemini CLI", "gemini", &["--acp"], "https://github.com/google-gemini/gemini-cli", Some("gemini cli")),
native("cursor", "Cursor", "cursor-agent", &["acp"], "https://cursor.com/cli", Some("cursor agent")),
native("opencode", "OpenCode", "opencode", &["acp"], "https://opencode.ai/docs", Some("opencode")),
native("goose", "Goose", "goose", &["acp"], "https://block.github.io/goose", Some("acp")),
native("kimi", "Kimi", "kimi", &["acp"], "https://github.com/MoonshotAI/kimi-cli", None),
native("devin", "Devin", "devin", &["acp"], "https://docs.devin.ai", None),
native("junie", "Junie", "junie", &["--acp=true"], "https://www.jetbrains.com/junie", None),
native("vibe", "Mistral Vibe", "vibe-acp", &[], "https://github.com/mistralai/mistral-vibe", None),
native("hermes", "Hermes", "hermes", &["acp"], "https://github.com/NousResearch/hermes-agent", None),
native("openhands", "OpenHands", "openhands", &["acp"], "https://docs.all-hands.dev", None),
native("stakpak", "Stakpak", "stakpak", &["acp"], "https://github.com/stakpak/agent", None),
native("vtcode", "VT Code", "vtcode", &["acp"], "https://github.com/vinhnx/vtcode", None),
native("poolside", "Poolside", "pool", &["acp"], "https://poolside.ai", None),
native("droid", "Droid", "droid", &["exec", "--output-format", "acp-daemon"], "https://docs.factory.ai", None),
npx("qwen", "Qwen Code", "@qwen-code/qwen-code", &["--acp"], "https://github.com/QwenLM/qwen-code"),
npx("kilo", "Kilo", "@kilocode/cli", &["acp"], "https://kilo.ai/docs"),
npx("cline", "Cline", "cline", &["--acp"], "https://docs.cline.bot"),
npx("auggie", "Auggie", "@augmentcode/auggie", &["--acp"], "https://docs.augmentcode.com"),
npx("copilot", "Copilot CLI", "@github/copilot", &["--acp"], "https://docs.github.com/copilot"),
npx("qoder", "Qoder", "@qoder-ai/qodercli", &["--acp"], "https://qoder.com"),
npx("codebuddy", "CodeBuddy", "@tencent-ai/codebuddy-code", &["--acp"], "https://www.codebuddy.ai"),
npx("pi", "Pi", "pi-acp", &[], "https://github.com/svkozak/pi-acp"),
npx("claude-acp", "Claude Code (ACP adapter)", "@zed-industries/claude-code-acp", &[], "https://github.com/zed-industries/claude-code-acp"),
npx("codex-acp", "Codex (ACP adapter)", "@zed-industries/codex-acp", &[], "https://github.com/zed-industries/codex-acp"),
];
pub fn find(id: &str) -> Option<&'static AgentDef> {
AGENTS.iter().find(|a| a.id == id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentState {
Ready { path: String, version: String },
/// An `npx` agent the user has not turned on.
NeedsEnable,
NotFound,
/// A program with the right name that is not the agent (e.g. the `goose` migration tool).
WrongProgram { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentStatus {
pub id: String,
pub state: AgentState,
}
/// Registry agents then custom ones.
#[cfg(not(target_arch = "wasm32"))]
pub fn detect_all(prefs: &ChatPrefs) -> Vec<AgentStatus> {
detect_all_in(prefs, super::path::resolved_path().as_ref())
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_all_in(prefs: &ChatPrefs, path: &std::ffi::OsStr) -> Vec<AgentStatus> {
// One thread per agent: each probe can wait out its timeout.
std::thread::scope(|s| {
let jobs: Vec<_> = AGENTS
.iter()
.map(|d| (d.id.to_owned(), s.spawn(move || detect_def(d, prefs, path))))
.chain(prefs.custom_agents.iter().map(|c| (c.id.clone(), s.spawn(move || detect_custom(c, path)))))
.collect();
jobs.into_iter().map(|(id, j)| AgentStatus { id, state: j.join().unwrap_or(AgentState::NotFound) }).collect()
})
}
#[cfg(not(target_arch = "wasm32"))]
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); // macOS scans a program on its first run, which can take seconds under load
/// `--help` of a node CLI can take a moment longer than `--version`.
#[cfg(not(target_arch = "wasm32"))]
const HELP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
#[cfg(not(target_arch = "wasm32"))]
const PROBE_MAX_BYTES: u64 = 64 * 1024;
/// The override from Settings, if any. The old `claude_path`/`codex_path` fields win for their
/// agent until Settings writes `agents` instead.
#[cfg(not(target_arch = "wasm32"))]
fn path_override<'a>(id: &str, prefs: &'a ChatPrefs) -> Option<&'a str> {
let legacy = match id {
"claude-code" => prefs.claude_path.as_deref(),
"codex" => prefs.codex_path.as_deref(),
_ => None,
};
legacy.or_else(|| prefs.agents.get(id)?.path.as_deref()).map(str::trim).filter(|p| !p.is_empty())
}
#[cfg(not(target_arch = "wasm32"))]
fn args_override<'a>(id: &str, prefs: &'a ChatPrefs) -> Option<&'a str> {
prefs.agents.get(id)?.args.as_deref().map(str::trim).filter(|a| !a.is_empty())
}
#[cfg(not(target_arch = "wasm32"))]
fn is_enabled(def: &AgentDef, prefs: &ChatPrefs) -> bool {
!matches!(def.kind, AgentKind::Npx { .. }) || prefs.agents.get(def.id).is_some_and(|a| a.enabled)
}
#[cfg(not(target_arch = "wasm32"))]
fn locate(binary: &str, over: Option<&str>, path: &std::ffi::OsStr) -> Option<PathBuf> {
match over {
Some(p) => PathBuf::from(p).is_file().then(|| PathBuf::from(p)),
None => super::path::which_in(binary, path),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_def(def: &AgentDef, prefs: &ChatPrefs, path: &std::ffi::OsStr) -> AgentState {
let over = path_override(def.id, prefs);
if !is_enabled(def, prefs) {
return AgentState::NeedsEnable;
}
// An npx agent needs npx itself; a path override points at that program instead.
let Some(bin) = locate(def.binary, over, path) else { return AgentState::NotFound };
let shown = bin.display().to_string();
if matches!(def.kind, AgentKind::Npx { .. }) {
return AgentState::Ready { path: shown, version: String::new() };
}
let version = probe(&bin, "--version", path, PROBE_TIMEOUT);
if let Some(want) = def.identity {
let hit = |o: &Option<String>| o.as_ref().is_some_and(|o| o.to_lowercase().contains(want));
if !hit(&version) && !hit(&probe(&bin, "--help", path, HELP_TIMEOUT)) {
return AgentState::WrongProgram { path: shown };
}
}
AgentState::Ready { path: shown, version: version.as_deref().and_then(|v| v.lines().find(|l| !l.trim().is_empty())).unwrap_or_default().trim().to_owned() }
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_custom(c: &CustomAgent, path: &std::ffi::OsStr) -> AgentState {
match custom_program(c, path) {
Some(p) => AgentState::Ready { path: p.display().to_string(), version: String::new() },
None => AgentState::NotFound,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn custom_program(c: &CustomAgent, path: &std::ffi::OsStr) -> Option<PathBuf> {
let cmd = c.command.trim();
if cmd.is_empty() {
return None;
}
if cmd.contains(['/', '\\']) {
return PathBuf::from(cmd).is_file().then(|| PathBuf::from(cmd));
}
super::path::which_in(cmd, path)
}
/// Runs `bin arg` and returns stdout + stderr; None when it fails to start or times out. No
/// credentials in the environment, PATH as the agents get it.
#[cfg(not(target_arch = "wasm32"))]
fn probe(bin: &std::path::Path, arg: &str, path: &std::ffi::OsStr, timeout: std::time::Duration) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
let mut cmd = Command::new(bin);
cmd.arg(arg).env("PATH", path).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
for k in super::path::CREDENTIAL_ENV {
cmd.env_remove(k);
}
// ETXTBSY (26): a binary just written (installer, test) is briefly still open for writing.
let mut child = (0..5).find_map(|_| match cmd.spawn() {
Err(e) if e.raw_os_error() == Some(26) => {
std::thread::sleep(std::time::Duration::from_millis(30));
None
}
r => Some(r),
})?.ok()?;
let (tx, rx) = std::sync::mpsc::channel();
for pipe in [child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>), child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>)].into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
let mut s = String::new();
let _ = pipe.take(PROBE_MAX_BYTES).read_to_string(&mut s);
let _ = tx.send(s);
});
}
drop(tx);
// Both pipes close when the program exits; a hung program hits the deadline and is killed.
let deadline = std::time::Instant::now() + timeout;
let mut out = String::new();
let done = loop {
match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) {
Ok(s) => out.push_str(&s),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break true,
Err(_) => break false,
}
};
let _ = child.kill();
let _ = child.wait();
done.then_some(out)
}
/// What the ACP backend runs for this agent: program and args. None for an unknown id, a
/// `Direct` agent, an `npx` agent that is not enabled, or a program that is not on PATH.
#[cfg(not(target_arch = "wasm32"))]
pub fn launch_command(id: &str, prefs: &ChatPrefs) -> Option<(PathBuf, Vec<String>)> {
launch_command_in(id, prefs, super::path::resolved_path().as_ref())
}
#[cfg(not(target_arch = "wasm32"))]
fn launch_command_in(id: &str, prefs: &ChatPrefs, path: &std::ffi::OsStr) -> Option<(PathBuf, Vec<String>)> {
if let Some(c) = prefs.custom_agents.iter().find(|c| c.id == id) {
return Some((custom_program(c, path)?, split(&c.args)));
}
let def = find(id)?;
if !is_enabled(def, prefs) {
return None;
}
let program = locate(def.binary, path_override(id, prefs), path)?;
let args = args_override(id, prefs).map_or_else(|| def.launch.iter().map(|a| (*a).to_owned()).collect(), split);
match def.kind {
AgentKind::Native => Some((program, args)),
AgentKind::Npx { package } => Some((program, ["-y".to_owned(), package.to_owned()].into_iter().chain(args).collect())),
AgentKind::Direct => None,
}
}
// ponytail: whitespace split, no quoting; add shlex if a path with spaces is ever needed in args.
#[cfg(not(target_arch = "wasm32"))]
fn split(args: &str) -> Vec<String> {
args.split_whitespace().map(String::from).collect()
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::ui::settings::AgentPrefs;
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
fn fake_dir(scripts: &[(&str, &str)]) -> (PathBuf, OsString) {
let dir = std::env::temp_dir().join(format!("pc-agents-{}", fastrand::u64(..)));
std::fs::create_dir_all(&dir).unwrap();
for (name, body) in scripts {
let p = dir.join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = dir.clone().into_os_string();
(dir, path)
}
fn state(all: &[AgentStatus], id: &str) -> AgentState {
all.iter().find(|s| s.id == id).unwrap().state.clone()
}
#[test]
fn table_ids_are_unique_and_launchable() {
let mut ids: Vec<_> = AGENTS.iter().map(|a| a.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), AGENTS.len());
assert!(AGENTS.iter().all(|a| !a.name.is_empty() && !a.binary.is_empty() && a.docs_url.starts_with("https://")));
assert!(AGENTS.iter().all(|a| !matches!(a.kind, AgentKind::Native) || !a.launch.is_empty() || a.id == "vibe"));
}
#[test]
fn detects_ready_wrong_missing_and_npx_states() {
let (dir, path) = fake_dir(&[
("gemini", r#"if [ "$1" = --version ]; then echo 0.1.0; else echo "Gemini CLI help"; fi"#),
// The migration tool: version string and help with no "acp".
("goose", r#"if [ "$1" = --version ]; then echo "goose version: v3.27.1"; else echo "Usage: goose DRIVER DBSTRING"; fi"#),
("opencode", r#"echo "opencode 1.0""#),
("npx", "echo npx"),
]);
let mut prefs = ChatPrefs::default();
let all = detect_all_in(&prefs, &path);
assert_eq!(all.len(), AGENTS.len());
let shown = |n: &str| dir.join(n).display().to_string();
assert_eq!(state(&all, "gemini"), AgentState::Ready { path: shown("gemini"), version: "0.1.0".into() });
assert_eq!(state(&all, "opencode"), AgentState::Ready { path: shown("opencode"), version: "opencode 1.0".into() });
assert_eq!(state(&all, "goose"), AgentState::WrongProgram { path: shown("goose") });
assert_eq!(state(&all, "cursor"), AgentState::NotFound);
assert_eq!(state(&all, "pi"), AgentState::NeedsEnable);
prefs.agents.insert("pi".into(), AgentPrefs { enabled: true, ..Default::default() });
assert_eq!(state(&detect_all_in(&prefs, &path), "pi"), AgentState::Ready { path: shown("npx"), version: String::new() });
// Enabled but npx missing.
assert_eq!(state(&detect_all_in(&prefs, OsStr::new("/nonexistent")), "pi"), AgentState::NotFound);
std::fs::remove_dir_all(&dir).unwrap();
}
use std::ffi::OsStr;
#[test]
fn path_override_wins_and_hung_program_times_out() {
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "sleep 30")]);
let mut prefs = ChatPrefs::default();
prefs.agents.insert("opencode".into(), AgentPrefs { path: Some("/nonexistent/opencode".into()), ..Default::default() });
assert_eq!(state(&detect_all_in(&prefs, &path), "opencode"), AgentState::NotFound);
let t = std::time::Instant::now();
assert_eq!(probe(&dir.join("hang"), "--version", &path, std::time::Duration::from_millis(200)), None);
assert!(t.elapsed() < std::time::Duration::from_secs(5));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn launch_commands_for_native_npx_custom_and_direct() {
let (dir, path) = fake_dir(&[("gemini", ""), ("npx", ""), ("my-agent", "")]);
let mut prefs = ChatPrefs::default();
prefs.custom_agents.push(CustomAgent { id: "mine".into(), name: "Mine".into(), command: "my-agent".into(), args: "serve --stdio".into() });
let at = |n: &str| dir.join(n);
assert_eq!(launch_command_in("gemini", &prefs, &path), Some((at("gemini"), vec!["--acp".into()])));
assert_eq!(launch_command_in("mine", &prefs, &path), Some((at("my-agent"), vec!["serve".into(), "--stdio".into()])));
assert_eq!(launch_command_in("pi", &prefs, &path), None);
prefs.agents.insert("pi".into(), AgentPrefs { enabled: true, ..Default::default() });
assert_eq!(launch_command_in("pi", &prefs, &path), Some((at("npx"), vec!["-y".into(), "pi-acp".into()])));
prefs.agents.insert("gemini".into(), AgentPrefs { args: Some("--acp --debug".into()), ..Default::default() });
assert_eq!(launch_command_in("gemini", &prefs, &path).unwrap().1, ["--acp", "--debug"]);
assert_eq!(launch_command_in("claude-code", &prefs, &path), None);
assert_eq!(launch_command_in("nope", &prefs, &path), None);
assert_eq!(launch_command_in("cursor", &prefs, &path), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+2 -2
View File
@@ -148,7 +148,7 @@ mod process {
pub fn detect(binary: Option<&Path>) -> Option<(PathBuf, String)> {
let path = match binary {
Some(p) => p.to_path_buf(),
None => std::env::split_paths(&std::env::var_os("PATH")?).map(|d| d.join("claude")).find(|p| p.is_file())?,
None => crate::chat::path::which("claude")?,
};
let out = clean_env(&mut Command::new(&path)).arg("--version").output().ok().filter(|o| o.status.success())?;
let version = String::from_utf8_lossy(&out.stdout).split_whitespace().next()?.to_string();
@@ -184,7 +184,7 @@ mod process {
for k in CREDENTIAL_ENV {
cmd.env_remove(k);
}
cmd
crate::chat::path::apply(cmd)
}
/// Shared by the waiter thread and `stop`; only signalled while unreaped (`try_wait` is `None`),
+7 -7
View File
@@ -115,16 +115,15 @@ mod process {
pub(super) fn clean_env(cmd: &mut Command) -> &mut Command {
for k in CREDENTIAL_ENV { cmd.env_remove(k); }
cmd
crate::chat::path::apply(cmd)
}
pub fn detect(binary: Option<&Path>) -> Option<(PathBuf, String)> {
let candidates = match binary {
Some(path) => vec![path.to_path_buf()],
None => std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| dir.join(if cfg!(windows) { "codex.exe" } else { "codex" })).collect(),
let path = match binary {
Some(path) => path.to_path_buf(),
None => crate::chat::path::which("codex")?,
};
candidates.into_iter().find_map(|path| {
Some(path).and_then(|path| {
let output = clean_env(&mut Command::new(&path)).arg("--version").output().ok()?;
if !output.status.success() { return None; }
let version = String::from_utf8(output.stdout).ok()?.trim().to_owned();
@@ -197,7 +196,8 @@ mod process {
}
pub fn start(cfg: &TurnConfig, prompt: &str, tx: Sender<Event>) -> Result<Box<dyn Turn>, String> {
let mut command = Command::new(cfg.binary.as_deref().unwrap_or(Path::new("codex")));
let bin = cfg.binary.clone().or_else(|| crate::chat::path::which("codex")).ok_or("Codex (`codex`) was not found")?;
let mut command = Command::new(bin);
clean_env(&mut command).args(build_args(cfg, prompt)).current_dir(&cfg.project_dir);
spawn_turn(command, tx)
}
+13
View File
@@ -1,6 +1,9 @@
//! Chat: a conversation with a coding agent (Claude Code, Codex or an API endpoint). Each chat
//! is one JSON file under the project's `chats/` folder, see [`store`].
pub mod agents;
#[cfg(not(target_arch = "wasm32"))]
pub mod acp;
pub mod anthropic;
pub mod claude_code;
pub mod codex;
@@ -8,6 +11,8 @@ pub mod endpoint;
pub mod events;
#[cfg(not(target_arch = "wasm32"))]
pub mod openai;
#[cfg(not(target_arch = "wasm32"))]
pub mod path;
pub mod store;
#[cfg(not(target_arch = "wasm32"))]
pub mod tools;
@@ -17,6 +22,9 @@ use serde_json::Value;
/// Where images for CLI backends and the system prompt file are saved, under the project folder.
pub const TMP_DIR: &str = ".papercraft-chat-tmp";
/// Each agent's own setup page, linked from a missing agent's row.
pub const CLAUDE_DOCS: &str = "https://docs.anthropic.com/en/docs/claude-code/setup";
pub const CODEX_DOCS: &str = "https://github.com/openai/codex";
const ID_MAX: usize = 64;
/// Chat ids become file and folder names, so only ascii letters, digits, `-` and `_` pass: no
@@ -33,6 +41,8 @@ pub enum BackendKind {
Codex,
/// An API endpoint (any format); the name is kept for saved chats.
Anthropic,
/// Any agent that speaks the Agent Client Protocol; [`Backend::agent`] says which.
Acp,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -48,6 +58,9 @@ pub struct Backend {
/// The CLI's session id, so a follow-up message resumes the same session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli_session: Option<String>,
/// `Acp` chats: the registry id (`agents::AGENTS`) or custom agent id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
+150
View File
@@ -0,0 +1,150 @@
//! The PATH agents are found and run with. An app opened from the Dock gets the bare system PATH
//! (`/usr/bin:/bin:/usr/sbin:/sbin`), so `~/.local/bin` and Homebrew are invisible. The login
//! shell's PATH is read once, off the UI thread, and merged with the process PATH and the usual
//! install folders.
use std::ffi::{OsStr, OsString};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
const SHELL_TIMEOUT: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(20);
/// The PATH is framed by markers: rc/exit-hook noise on stdout before or after cannot replace it.
const SHELL_SCRIPT: &str = r#"printf '\n__PC_PATH_BEGIN__%s__PC_PATH_END__\n' "$PATH""#;
const PATH_BEGIN: &str = "__PC_PATH_BEGIN__";
const PATH_END: &str = "__PC_PATH_END__";
/// Same keys the agent spawns drop: a login shell must not hand them to anything.
pub(super) const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
/// Under `$HOME`.
const HOME_DIRS: [&str; 6] = [".local/bin", ".cargo/bin", ".bun/bin", ".npm-global/bin", ".volta/bin", ".nvm/current/bin"];
const SYSTEM_DIRS: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"];
static RESOLVED: OnceLock<String> = OnceLock::new();
/// Starts resolving in the background; call once at app start.
pub fn init() {
std::thread::spawn(|| {
resolved_path();
});
}
/// The merged PATH. Blocks only while the first resolution is still running.
pub fn resolved_path() -> &'static str {
RESOLVED.get_or_init(|| {
let shell = std::env::var("SHELL").ok().filter(|_| cfg!(unix)).and_then(|s| shell_path(&s, SHELL_SCRIPT, SHELL_TIMEOUT));
let home = std::env::var_os("HOME").map(PathBuf::from);
let extras = HOME_DIRS.iter().filter_map(|d| home.as_ref().map(|h| h.join(d))).chain(SYSTEM_DIRS.iter().map(PathBuf::from)).filter(|d| d.is_dir()).collect();
merge(shell.as_deref().unwrap_or_default(), &std::env::var_os("PATH").unwrap_or_default(), extras)
})
}
/// Shell PATH first (the user's own order), then the process PATH, then the extra folders; each
/// folder once.
fn merge(shell: &str, process: &OsStr, extras: Vec<PathBuf>) -> String {
let mut out: Vec<PathBuf> = vec![];
for d in std::env::split_paths(shell).chain(std::env::split_paths(process)).chain(extras) {
if !d.as_os_str().is_empty() && !out.contains(&d) {
out.push(d);
}
}
std::env::join_paths(out).map(|p| p.to_string_lossy().into_owned()).unwrap_or_default()
}
/// Runs `shell -l -c script` and returns the text between the PATH markers; None on failure or timeout.
fn shell_path(shell: &str, script: &str, timeout: Duration) -> Option<String> {
let mut cmd = Command::new(shell);
cmd.args(["-l", "-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
for k in CREDENTIAL_ENV {
cmd.env_remove(k);
}
let mut child = cmd.spawn().ok()?;
let mut stdout = child.stdout.take()?;
// Read on a thread: a shell stuck in an rc file never closes the pipe.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut s = String::new();
let _ = stdout.read_to_string(&mut s);
let _ = tx.send(s);
});
let start = Instant::now();
let out = loop {
match rx.recv_timeout(POLL) {
Ok(s) => break Some(s),
Err(_) if start.elapsed() >= timeout => break None,
Err(_) => {}
}
};
let _ = child.kill();
let _ = child.wait();
let out = out?;
let from = out.rfind(PATH_BEGIN)? + PATH_BEGIN.len();
let len = out[from..].find(PATH_END)?;
Some(out[from..from + len].trim().to_string()).filter(|p| !p.is_empty())
}
/// First executable named `name` on the resolved PATH.
pub fn which(name: &str) -> Option<PathBuf> {
which_in(name, OsStr::new(resolved_path()))
}
pub(super) fn which_in(name: &str, path: &OsStr) -> Option<PathBuf> {
let names: Vec<String> = if cfg!(windows) { ["exe", "cmd", "bat"].iter().map(|e| format!("{name}.{e}")).collect() } else { vec![name.into()] };
std::env::split_paths(path).flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| is_executable(p))
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
p.is_file()
}
/// Sets the merged PATH on a child, so node-based CLIs find `node`.
pub fn apply(cmd: &mut Command) -> &mut Command {
cmd.env("PATH", OsString::from(resolved_path()))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn merge_dedupes_and_keeps_order() {
let m = merge("/a:/b:/a", OsStr::new("/b:/c::/d"), vec!["/d".into(), "/e".into()]);
assert_eq!(m, "/a:/b:/c:/d:/e");
}
#[test]
fn which_finds_only_executables_on_the_given_path() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("pc-path-{}", fastrand::u64(..)));
std::fs::create_dir_all(&dir).unwrap();
let (bin, plain) = (dir.join("fake-agent"), dir.join("not-exec"));
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&plain, "x").unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = std::env::join_paths(["/nonexistent", dir.to_str().unwrap()]).unwrap();
assert_eq!(which_in("fake-agent", &path), Some(bin));
assert_eq!(which_in("not-exec", &path), None);
assert_eq!(which_in("missing", &path), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn shell_path_reads_between_markers_and_times_out() {
let script = r#"echo noise; printf '\n__PC_PATH_BEGIN__%s__PC_PATH_END__\n' /x:/y; echo exit-hook-noise"#;
assert_eq!(shell_path("/bin/sh", script, Duration::from_secs(3)).as_deref(), Some("/x:/y"));
assert_eq!(shell_path("/bin/sh", SHELL_SCRIPT, Duration::from_secs(3)).is_some(), true);
assert_eq!(shell_path("/bin/sh", "echo /only/noise", Duration::from_secs(3)), None);
let t = Instant::now();
assert_eq!(shell_path("/bin/sh", "sleep 10", Duration::from_millis(200)), None);
assert!(t.elapsed() < Duration::from_secs(3));
assert_eq!(shell_path("/nonexistent-shell", SHELL_SCRIPT, Duration::from_secs(1)), None);
}
}
+1
View File
@@ -95,6 +95,7 @@ mod tests {
effort: None,
endpoint: None,
cli_session: Some("s1".into()),
agent: None,
},
messages: vec![Message {
role: Role::Assistant,
+409
View File
@@ -0,0 +1,409 @@
//! Undoable annotation edits: each call is one history step.
use super::Editor;
use crate::history::Tx;
use crate::model::annotation::{Annotation, AnnotationCategory, AnnotationTemplate, builtin_templates};
use crate::variables::{Error, PAGE_TEMPLATE_KEY};
impl Tx<'_> {
/// Add an annotation (its id is allocated here). Returns the id.
pub fn add_annotation(&mut self, mut a: Annotation) -> u64 {
self.snapshot_annotations();
a.id = self.doc.alloc_id().0;
if a.created == 0 {
a.created = crate::project::format::now_ms();
}
let id = a.id;
self.doc.annotations.push(a);
id
}
/// Add annotation copies (see `annotation::copies_for`) under fresh ids.
pub fn add_annotation_copies(&mut self, copies: Vec<Annotation>) {
for a in copies {
self.add_annotation(a);
}
}
/// Change an annotation in place; `id` and `target` stay as they were unless `f` sets them.
pub fn update_annotation(&mut self, id: u64, f: impl FnOnce(&mut Annotation)) -> bool {
if !self.doc.annotations.iter().any(|a| a.id == id) {
return false;
}
self.snapshot_annotations();
let a = self.doc.annotations.iter_mut().find(|a| a.id == id).unwrap();
f(a);
a.id = id;
true
}
pub fn delete_annotation(&mut self, id: u64) -> bool {
if !self.doc.annotations.iter().any(|a| a.id == id) {
return false;
}
self.snapshot_annotations();
self.doc.annotations.retain(|a| a.id != id);
true
}
}
impl Editor {
pub fn add_annotation(&mut self, a: Annotation) -> u64 {
self.edit("Add annotation", |tx| tx.add_annotation(a))
}
/// Update label, body, category, position or anchor of one annotation.
pub fn update_annotation(&mut self, id: u64, f: impl FnOnce(&mut Annotation)) -> bool {
self.edit("Edit annotation", |tx| tx.update_annotation(id, f))
}
pub fn delete_annotation(&mut self, id: u64) -> bool {
self.edit("Delete annotation", |tx| tx.delete_annotation(id))
}
/// Page `page`'s own template override, if set.
pub fn page_template_id(&self, page: usize) -> Option<&str> {
self.pages[page].modes.get(PAGE_TEMPLATE_KEY).map(String::as_str)
}
/// The template in effect on page `page`: its override, else the project default.
pub fn active_template(&self, page: usize) -> AnnotationTemplate {
self.design.active_template(self.page_template_id(page))
}
/// Set the project's default template (`None` = Product spec). Undoable.
pub fn set_default_template(&mut self, id: Option<&str>) -> Result<(), Error> {
if let Some(id) = id
&& !self.design.templates().iter().any(|t| t.id == id)
{
return Err(Error::NotFound(id.into()));
}
self.edit_design("Set default annotation template", |d| {
d.annotation_template = id.map(String::from);
Ok(())
})
.map(|_| ())
}
/// Override the template on one page (`None` = follow the project default). Undoable.
pub fn set_page_template(&mut self, page: usize, id: Option<&str>) -> Result<(), Error> {
if let Some(id) = id
&& !self.design.templates().iter().any(|t| t.id == id)
{
return Err(Error::NotFound(id.into()));
}
self.set_page_template_on(page, id);
Ok(())
}
/// Add a template, or replace the one with the same id. Undoable.
pub fn upsert_template(&mut self, t: AnnotationTemplate) -> Result<(), Error> {
self.edit_design("Edit annotation template", |d| {
let mut all = d.templates();
match all.iter_mut().find(|x| x.id == t.id) {
Some(x) => *x = t,
None => all.push(t),
}
d.annotation_templates = all;
Ok(())
})
.map(|_| ())
}
/// Delete a template (the last one stays). A default pointing at it goes back to Product spec.
pub fn delete_template(&mut self, id: &str) -> Result<(), Error> {
self.edit_design("Delete annotation template", |d| {
let mut all = d.templates();
if all.len() < 2 {
return Err(Error::LastMode);
}
let n = all.len();
all.retain(|t| t.id != id);
if all.len() == n {
return Err(Error::NotFound(id.into()));
}
d.annotation_templates = all;
if d.annotation_template.as_deref() == Some(id) {
d.annotation_template = None;
}
Ok(())
})
.map(|_| ())
}
/// Copy a template under a fresh id ("<name> copy"). Returns the new id. Undoable.
pub fn duplicate_template(&mut self, id: &str) -> Result<String, Error> {
self.edit_design("Duplicate annotation template", |d| {
let mut all = d.templates();
let src = all.iter().find(|t| t.id == id).ok_or_else(|| Error::NotFound(id.into()))?;
let mut copy = src.clone();
let mut n = 1;
while all.iter().any(|t| t.id == copy.id) {
n += 1;
copy.id = format!("{id}-{n}");
}
copy.name = format!("{} copy", src.name);
let new = copy.id.clone();
all.push(copy);
d.annotation_templates = all;
Ok(new)
})
.map(|(id, _)| id)
}
/// Save an edited template together with the category list it was edited against. A changed
/// category key is renamed in every template. One undo step per page. Duplicate keys are refused.
pub fn save_template(&mut self, t: AnnotationTemplate, cats: Vec<AnnotationCategory>) -> Result<(), Error> {
self.edit_design("Edit annotation template", |d| {
let old = d.categories();
for (i, c) in cats.iter().enumerate() {
if cats[..i].iter().any(|o| o.key().eq_ignore_ascii_case(c.key())) {
return Err(Error::Duplicate(c.key().into()));
}
}
let renames: Vec<(String, String)> = cats
.iter()
.filter_map(|c| old.iter().find(|o| o.id == c.id).map(|o| (o.key().to_string(), c.key().to_string())))
.filter(|(a, b)| a != b)
.collect();
let rename = |t: &mut AnnotationTemplate| {
for tc in &mut t.categories {
if let Some((_, to)) = renames.iter().find(|(from, _)| *from == tc.key) {
tc.key.clone_from(to);
}
}
};
let mut all = d.templates();
all.iter_mut().for_each(rename);
let mut t = t;
rename(&mut t);
match all.iter_mut().find(|x| x.id == t.id) {
Some(x) => *x = t,
None => all.push(t),
}
d.annotation_templates = all;
d.annotation_categories = cats;
Ok(())
})
.map(|_| ())
}
/// Put a built-in template back to its original content. Errors for a custom template.
pub fn reset_template(&mut self, id: &str) -> Result<(), Error> {
let original = builtin_templates()
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| Error::NotFound(id.into()))?;
self.upsert_template(original)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::{Rect, V2, v2};
use crate::model::annotation::{self, Anchor, Side};
use crate::model::{Node, NodeId, NodeKind};
fn frame(e: &mut Editor, r: Rect) -> NodeId {
e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, r), None)
}
fn child(e: &mut Editor, parent: NodeId, r: Rect) -> NodeId {
e.add_node(Node::new(NodeId(0), NodeKind::Rect, r), Some(parent))
}
#[test]
fn add_update_delete_undo_one_step_each() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(0.0, 0.0, 100.0, 100.0));
let base = e.doc.clone();
let mut a = Annotation::new(Some(f), "Label", "data");
a.position = v2(120.0, 0.0);
let id = e.add_annotation(a);
let added = e.doc.clone();
assert_eq!(e.doc.annotations.len(), 1);
assert!(e.update_annotation(id, |a| {
a.label = "New".into();
a.body = "text".into();
a.category = "content".into();
a.position = v2(5.0, 6.0);
a.anchor = Anchor { side: Side::Left, t: 0.25 };
}));
let updated = e.doc.clone();
assert_eq!(updated.annotations[0].label, "New");
assert!(e.delete_annotation(id));
assert!(e.doc.annotations.is_empty());
assert!(!e.delete_annotation(id), "missing id is a no-op");
e.undo();
assert_eq!(e.doc, updated);
e.undo();
assert_eq!(e.doc, added);
e.undo();
assert_eq!(e.doc, base);
e.redo();
assert_eq!(e.doc, added);
}
#[test]
fn move_and_resize_target_keep_anchor_and_card() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
let mut a = Annotation::new(Some(c), "x", "data");
a.anchor = Anchor { side: Side::Right, t: 0.5 };
a.position = v2(150.0, -10.0);
let id = e.add_annotation(a);
let a0 = e.doc.annotations[0].clone();
assert_eq!(annotation::anchor_world(&e.doc, &a0), v2(210.0, 145.0));
assert_eq!(annotation::card_world(&e.doc, &a0), v2(260.0, 110.0));
e.edit("move", |tx| tx.node_mut(c).unwrap().rect = Rect::new(30.0, 40.0, 200.0, 100.0));
let a1 = e.doc.annotations[0].clone();
assert_eq!(a1, a0, "annotation itself untouched");
assert_eq!(annotation::anchor_world(&e.doc, &a1), v2(330.0, 190.0));
assert_eq!(annotation::card_world(&e.doc, &a1), v2(280.0, 130.0));
e.edit("move frame", |tx| tx.node_mut(f).unwrap().rect.x += 1000.0);
assert_eq!(annotation::anchor_world(&e.doc, &e.doc.annotations[0]), v2(1330.0, 190.0));
let _ = id;
}
#[test]
fn deleting_target_detaches_and_undo_reattaches() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
let mut a = Annotation::new(Some(c), "x", "data");
a.anchor = Anchor { side: Side::Bottom, t: 1.0 };
a.position = v2(150.0, 0.0);
e.add_annotation(a);
let before = e.doc.clone();
let (anchor, card) = {
let a = &e.doc.annotations[0];
(annotation::anchor_world(&e.doc, a), annotation::card_world(&e.doc, a))
};
e.edit("Delete", |tx| tx.remove(f));
let a = &e.doc.annotations[0];
assert!(a.detached && a.target.is_none());
assert_eq!(a.point, Some(anchor));
assert_eq!(annotation::anchor_world(&e.doc, a), anchor);
assert_eq!(annotation::card_world(&e.doc, a), card);
e.undo();
assert_eq!(e.doc, before);
}
#[test]
fn auto_position_sits_right_of_frame_and_stacks() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
let p1 = annotation::auto_position(&e.doc, c);
let mut a = Annotation::new(Some(c), "1", "rule");
a.position = p1;
e.add_annotation(a);
let w1 = annotation::card_world(&e.doc, &e.doc.annotations[0]);
assert_eq!(w1, v2(524.0, 120.0));
let p2 = annotation::auto_position(&e.doc, c);
let w2 = v2(110.0, 120.0) + p2;
assert_eq!(w2.x, w1.x);
assert!(w2.y >= w1.y + annotation::CARD_H, "stacked below, no overlap");
}
#[test]
fn numbering_is_per_frame_in_reading_order() {
let mut e = Editor::default();
let f1 = frame(&mut e, Rect::new(0.0, 0.0, 300.0, 300.0));
let f2 = frame(&mut e, Rect::new(500.0, 0.0, 300.0, 300.0));
let low = child(&mut e, f1, Rect::new(10.0, 200.0, 20.0, 20.0));
let right = child(&mut e, f1, Rect::new(200.0, 10.0, 20.0, 20.0));
let left = child(&mut e, f1, Rect::new(10.0, 10.0, 20.0, 20.0));
let other = child(&mut e, f2, Rect::new(10.0, 10.0, 20.0, 20.0));
let mut ids = Vec::new();
for t in [Some(low), Some(right), Some(left), Some(other), None] {
ids.push(e.add_annotation(Annotation::new(t, "n", "data")));
}
let n = annotation::numbers(&e.doc);
assert_eq!(n[&ids[2]], (Some(f1), 1));
assert_eq!(n[&ids[1]], (Some(f1), 2));
assert_eq!(n[&ids[0]], (Some(f1), 3));
assert_eq!(n[&ids[3]], (Some(f2), 1));
assert_eq!(n[&ids[4]], (None, 1));
}
#[test]
fn paste_of_a_frame_copies_its_annotations() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(0.0, 0.0, 200.0, 200.0));
let c = child(&mut e, f, Rect::new(10.0, 10.0, 20.0, 20.0));
e.add_annotation(Annotation::new(Some(c), "on child", "data"));
e.add_annotation(Annotation::new(Some(f), "on frame", "content"));
e.add_annotation(Annotation::new(None, "loose", "content"));
e.select(vec![f]);
e.copy_internal();
e.paste(V2::ZERO);
assert_eq!(e.doc.annotations.len(), 5);
let copy = e.selection[0];
let copy_child = e.doc.get(copy).unwrap().children[0];
let mut ids: Vec<u64> = e.doc.annotations.iter().map(|a| a.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 5, "fresh ids");
let on = |t| e.doc.annotations.iter().filter(|a| a.target == Some(t)).count();
assert_eq!((on(f), on(c), on(copy), on(copy_child)), (1, 1, 1, 1));
e.undo();
assert_eq!(e.doc.annotations.len(), 3);
}
#[test]
fn restore_snapshot_restores_annotations_in_one_undo() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(0.0, 0.0, 100.0, 100.0));
e.add_annotation(Annotation::new(Some(f), "old", "data"));
let snap = e.doc.clone();
e.delete_annotation(snap.annotations[0].id);
e.add_annotation(Annotation::new(Some(f), "agent", "rule"));
let changed = e.doc.clone();
e.restore_snapshot(&snap);
assert_eq!(e.doc.annotations, snap.annotations, "target kept, not detached");
e.undo();
assert_eq!(e.doc.annotations, changed.annotations);
}
#[test]
fn revert_to_snapshot_without_annotations_removes_them_and_undo_restores() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(0.0, 0.0, 100.0, 100.0));
let snap = e.doc.clone();
e.add_annotation(Annotation::new(Some(f), "agent", "rule"));
e.restore_snapshot(&snap);
assert!(e.doc.annotations.is_empty());
e.undo();
assert_eq!(e.doc.annotations.len(), 1);
assert_eq!(e.doc.annotations[0].target, Some(f));
}
#[test]
fn save_template_renames_category_key_everywhere_in_one_undo() {
let mut e = Editor::default();
let mut cats = e.design.categories();
cats[0].key = "policy".into();
let mut t = e.design.template_by_ref("product-spec").unwrap();
t.name = "Spec".into();
let before = e.design.clone();
let undo_len = e.history.undo_len();
e.save_template(t, cats.clone()).unwrap();
assert_eq!(e.history.undo_len(), undo_len + 1);
for t in e.design.templates() {
assert!(t.categories.iter().all(|c| c.key != "rule"), "{}", t.name);
}
assert!(e.active_template(0).enabled("policy").is_some());
assert!(e.design.category_by_key("policy").is_some());
let mut dup = cats.clone();
dup[1].key = "POLICY".into();
let t = e.design.template_by_ref("minimal").unwrap();
let now = e.design.clone();
assert!(e.save_template(t, dup).is_err());
assert_eq!(e.design, now);
e.undo();
assert_eq!(e.design, before);
}
}
+3
View File
@@ -15,7 +15,10 @@ pub enum UiEvent {
ToggleChat,
ToggleHelp,
ToggleGrid,
ToggleAnnotations,
OpenVariables,
/// Open the New annotation popover on this layer.
Annotate(NodeId),
}
impl Editor {
+24
View File
@@ -61,7 +61,11 @@ impl Editor {
// A project that never went through the session (no design.json yet) starts from what
// its first page already holds.
if self.design.collections.is_empty() {
let old = std::mem::take(&mut self.design);
self.design = variables::migrate_from_document(self.page_doc(0));
self.design.annotation_categories = old.annotation_categories;
self.design.annotation_templates = old.annotation_templates;
self.design.annotation_template = old.annotation_template;
}
let before = self.design.clone();
let mut next = before.clone();
@@ -208,6 +212,26 @@ impl Editor {
Ok(n.values().sum())
}
/// Set (or clear, with `None`) page `page`'s annotation template override. One undo step.
pub fn set_page_template_on(&mut self, page: usize, template: Option<&str>) {
let old = self.pages[page].modes.clone();
let mut modes = old.clone();
match template {
Some(t) => modes.insert(variables::PAGE_TEMPLATE_KEY.into(), t.into()),
None => modes.remove(variables::PAGE_TEMPLATE_KEY),
};
if modes == old {
return;
}
let cache = self.design.cache_for(&modes);
self.with_page(page, |ed| {
ed.rebuild_cache("Set annotation template", &cache, &BTreeMap::new(), None, None, |rec| {
rec.page_modes = Some((old, modes.clone()));
}, true)
});
self.pages[page].modes = modes;
}
/// Bind `field` of `ids` to variable `css` (`None` detaches: the current value stays, the
/// binding goes). The value applies at once. One undo step. On an instance the field becomes
/// an override.
+18 -1
View File
@@ -2,6 +2,7 @@
//!
//! It consumes platform-agnostic [`InputEvent`]s, so every interaction can be scripted in tests.
pub mod annotations;
pub mod commands;
pub mod design;
pub mod hit;
@@ -43,10 +44,11 @@ pub enum Tool {
Pencil,
Eraser,
Laser,
Annotate,
}
impl Tool {
pub const ALL: [Tool; 16] = [
pub const ALL: [Tool; 17] = [
Tool::Select,
Tool::Hand,
Tool::Frame,
@@ -63,6 +65,7 @@ impl Tool {
Tool::Pencil,
Tool::Eraser,
Tool::Laser,
Tool::Annotate,
];
pub fn label(self) -> &'static str {
match self {
@@ -82,6 +85,7 @@ impl Tool {
Tool::Pencil => "Pencil",
Tool::Eraser => "Eraser",
Tool::Laser => "Laser pointer",
Tool::Annotate => "Annotate",
}
}
pub fn shortcut(self) -> &'static str {
@@ -102,6 +106,7 @@ impl Tool {
Tool::Pencil => "⇧P",
Tool::Eraser => "E",
Tool::Laser => "K",
Tool::Annotate => "N",
}
}
}
@@ -810,6 +815,13 @@ impl Editor {
}
Tool::Arrow => self.arrow_down(if self.snapping { world.round() } else { world }),
Tool::Sticky => self.sticky_down(world),
Tool::Annotate => {
self.end_text_edit();
if let Some(id) = hit::hit_test(&self.doc, world, self.camera.zoom) {
self.selection = vec![id];
self.ui_events.push(commands::UiEvent::Annotate(id));
}
}
Tool::Laser => {
self.laser_point(world, time_ms);
self.gesture = Gesture::Laser;
@@ -2028,6 +2040,7 @@ impl Editor {
A::ToggleChat => self.ui_events.push(commands::UiEvent::ToggleChat),
A::ToggleHelp => self.ui_events.push(commands::UiEvent::ToggleHelp),
A::ToggleGrid => self.ui_events.push(commands::UiEvent::ToggleGrid),
A::ToggleAnnotations => self.ui_events.push(commands::UiEvent::ToggleAnnotations),
A::OpenVariables => self.ui_events.push(commands::UiEvent::OpenVariables),
A::SelectNext => self.select_sibling(true),
A::SelectPrev => self.select_sibling(false),
@@ -2452,6 +2465,10 @@ pub fn insert_subtrees_tx(
for id in map.values() {
crate::editor::whiteboard::update_connector(tx, *id);
}
if same_page {
let copies = crate::model::annotation::copies_for(&tx.doc().annotations.clone(), &map);
tx.add_annotation_copies(copies);
}
new_roots
}
+2
View File
@@ -636,6 +636,8 @@ fn replace_content(tx: &mut crate::history::Tx, snap: &Document) {
for root in tx.doc().root.clone() {
tx.remove(root);
}
tx.snapshot_annotations();
tx.doc.annotations.clone_from(&snap.annotations);
for root in snap.root.clone() {
for (i, id) in snap.subtree(root).into_iter().enumerate() {
let Some(node) = snap.get(id).cloned() else {
+6
View File
@@ -60,6 +60,7 @@ pub enum Action {
ToggleChat,
ToggleHelp,
ToggleGrid,
ToggleAnnotations,
OpenVariables,
SelectNext,
SelectPrev,
@@ -135,6 +136,7 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
(Char('l'), SHIFT, A::Tool(Tool::Arrow), "Arrow tool"),
(Char('s'), PLAIN, A::Tool(Tool::Sticky), "Sticky note"),
(Char('k'), PLAIN, A::Tool(Tool::Laser), "Laser pointer"),
(Char('n'), PLAIN, A::Tool(Tool::Annotate), "Annotate tool"),
(Char('1'), PLAIN, A::Tool(Tool::Select), "Select tool"),
(Char('2'), PLAIN, A::Tool(Tool::Rect), "Rectangle tool"),
(Char('3'), PLAIN, A::Tool(Tool::Diamond), "Diamond tool"),
@@ -167,6 +169,8 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
(Char('/'), CMD, A::ToggleHelp, "Keyboard shortcuts"),
(Char('?'), SHIFT, A::ToggleHelp, "Keyboard shortcuts"),
(Char('\''), CMD_SHIFT, A::ToggleGrid, "Show / hide dot grid"),
// Shift+A is Add auto layout, so annotations take Cmd+Shift+A.
(Char('a'), CMD_SHIFT, A::ToggleAnnotations, "Cycle annotations: full, badges, hidden"),
// Cmd+Alt+V is taken by Paste properties.
(Char('v'), CMD_ALT_SHIFT, A::OpenVariables, "Variables"),
(Tab, PLAIN, A::SelectNext, "Select next sibling"),
@@ -424,6 +428,7 @@ pub fn category(a: Action) -> &'static str {
| A::ZoomSelection
| A::ToggleSnapping
| A::ToggleGrid
| A::ToggleAnnotations
| A::OpenVariables
| A::ToggleUi
| A::ToggleChat
@@ -608,6 +613,7 @@ pub fn menus() -> Vec<(&'static str, Vec<MenuEntry>)> {
S,
I("Snapping", A::ToggleSnapping),
I("Dot Grid", A::ToggleGrid),
I("Annotations", A::ToggleAnnotations),
I("Show/Hide UI", A::ToggleUi),
I("Design / Chat Mode", A::ToggleChat),
I("Keyboard Shortcuts", A::ToggleHelp),
+37 -1
View File
@@ -5,6 +5,7 @@
//! and pushes a [`Change`] that can be applied in either direction.
use crate::geom::V2;
use crate::model::annotation::{self, Annotation};
use crate::model::{Color, Document, Node, NodeId, VarState};
use crate::variables::{Cache, DesignSystem, PageModes};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -28,6 +29,8 @@ pub struct Change {
design: Option<(Box<DesignSystem>, Box<DesignSystem>)>,
/// The page's active mode per collection before/after (`Editor::set_page_mode`).
page_modes: Option<(PageModes, PageModes)>,
/// The page's annotations before/after.
annotations: Option<(Vec<Annotation>, Vec<Annotation>)>,
next_id: (u64, u64),
coalesce_key: Option<String>,
}
@@ -84,6 +87,9 @@ impl Change {
if let Some((before, after)) = &self.variables {
doc.restore_vars(if forward { after } else { before });
}
if let Some((before, after)) = &self.annotations {
doc.annotations = if forward { after } else { before }.clone();
}
doc.next_id = if forward {
self.next_id.1
} else {
@@ -102,6 +108,7 @@ pub struct Recording {
variables_before: Option<VarState>,
pub(crate) design: Option<(DesignSystem, DesignSystem)>,
pub(crate) page_modes: Option<(PageModes, PageModes)>,
annotations_before: Option<Vec<Annotation>>,
next_id_before: u64,
}
@@ -447,11 +454,29 @@ impl<'a> Tx<'a> {
self.doc.nodes.insert(node.id, node);
}
/// Remove a node and its subtree.
pub(crate) fn snapshot_annotations(&mut self) {
if self.rec.annotations_before.is_none() {
self.rec.annotations_before = Some(self.doc.annotations.clone());
}
}
/// Remove a node and its subtree. Annotations on it stay, detached where they were.
pub fn remove(&mut self, id: NodeId) {
let Some(parent) = self.doc.get(id).map(|n| n.parent) else {
return;
};
let gone = self.doc.subtree(id);
if self.doc.annotations.iter().any(|a| a.target.is_some_and(|t| gone.contains(&t))) {
self.snapshot_annotations();
let mut list = std::mem::take(&mut self.doc.annotations);
for a in &mut list {
if a.target.is_some_and(|t| gone.contains(&t)) {
let (point, card) = (annotation::anchor_world(self.doc, a), annotation::card_world(self.doc, a));
(a.target, a.point, a.position, a.detached) = (None, Some(point), card, true);
}
}
self.doc.annotations = list;
}
self.touch_parent_list(parent);
if let Some(list) = self.doc.children_mut(parent) {
list.retain(|c| *c != id);
@@ -533,7 +558,12 @@ impl History {
.filter(|(b, a)| b != a)
.map(|(b, a)| (Box::new(b), Box::new(a)));
let page_modes = rec.page_modes.filter(|(b, a)| b != a);
let annotations = rec
.annotations_before
.filter(|b| *b != doc.annotations)
.map(|b| (b, doc.annotations.clone()));
if entries.is_empty()
&& annotations.is_none()
&& root.is_none()
&& variables.is_none()
&& design.is_none()
@@ -548,6 +578,7 @@ impl History {
variables,
design,
page_modes,
annotations,
next_id: (rec.next_id_before, doc.next_id),
coalesce_key,
};
@@ -645,6 +676,11 @@ fn merge(older: &mut Change, newer: Change) {
(m @ None, Some(nm)) => *m = Some(nm),
_ => {}
}
match (&mut older.annotations, newer.annotations) {
(Some((_, after)), Some((_, new_after))) => *after = new_after,
(a @ None, Some(na)) => *a = Some(na),
_ => {}
}
older.next_id.1 = newer.next_id.1;
}
+68 -4
View File
@@ -1174,10 +1174,55 @@ pub fn to_egui(doc: &Document, root: NodeId) -> String {
out
}
pub(crate) fn annotation_comments(
doc: &Document,
root: NodeId,
categories: &[crate::model::annotation::AnnotationCategory],
) -> String {
let ids = doc.subtree(root);
let numbers = crate::model::annotation::numbers(doc);
let mut notes: Vec<_> = doc
.annotations
.iter()
.filter(|a| a.target.is_some_and(|id| ids.contains(&id)))
.collect();
notes.sort_by_key(|a| (numbers[&a.id].0.map(|id| id.0), numbers[&a.id].1));
let mut out = String::new();
for note in notes {
let category = categories
.iter()
.find(|c| c.id == note.category)
.map(|c| c.name.as_str())
.unwrap_or(&note.category);
out.push_str(&format!(
"// {} {category}: {}\n",
numbers[&note.id].1, note.label
));
for line in note.body.lines() {
out.push_str(&format!("// {line}\n"));
}
}
out
}
/// Generate code for `root` in the requested `target` format (`tailwind`/`jsx`, `react`/`tsx`,
/// `angular`/`ng`, `html`/`css`, or `egui`/`rust`).
pub fn code_for_target(doc: &Document, root: NodeId, target: &str) -> Result<String, String> {
match target.trim().to_ascii_lowercase().as_str() {
code_for_target_with_categories(
doc,
root,
target,
&crate::model::annotation::default_categories(),
)
}
pub(crate) fn code_for_target_with_categories(
doc: &Document,
root: NodeId,
target: &str,
categories: &[crate::model::annotation::AnnotationCategory],
) -> Result<String, String> {
let code = match target.trim().to_ascii_lowercase().as_str() {
"tailwind" | "jsx" => Ok(to_tailwind_jsx(doc, root)),
"react" | "tsx" => Ok(to_react_tsx(doc, root)),
"angular" | "ng" => Ok(to_angular(doc, root)),
@@ -1186,7 +1231,11 @@ pub fn code_for_target(doc: &Document, root: NodeId, target: &str) -> Result<Str
other => Err(format!(
"unknown target `{other}` (expected react, tailwind, angular, html, or egui)"
)),
}
}?;
Ok(format!(
"{}{code}",
annotation_comments(doc, root, categories)
))
}
fn sizing_name(s: Sizing) -> &'static str {
@@ -1560,10 +1609,17 @@ fn build_spec_tree(
/// Build the deterministic AI Layout & Enforcement Spec JSON for `root` and `target`.
pub fn to_ai_spec(doc: &Document, root: NodeId, target: &str) -> Result<Value, String> {
to_ai_spec_with_categories(doc, root, target, &crate::model::annotation::default_categories())
}
pub(crate) fn to_ai_spec_with_categories(
doc: &Document, root: NodeId, target: &str,
categories: &[crate::model::annotation::AnnotationCategory],
) -> Result<Value, String> {
let Some(root_node) = doc.get(root) else {
return Err(format!("no node #{id}", id = root.0));
};
let reference_code = code_for_target(doc, root, target)?;
let reference_code = code_for_target_with_categories(doc, root, target, categories)?;
let mut components = Vec::new();
let mut rules = Vec::new();
@@ -1635,6 +1691,7 @@ pub fn to_ai_spec(doc: &Document, root: NodeId, target: &str) -> Result<Value, S
"componentDefinitions": definitions,
"layoutTree": layout_tree,
"enforcementRules": rules,
"annotations": annotation_comments(doc, root, categories).lines().collect::<Vec<_>>(),
"referenceCode": reference_code,
}))
}
@@ -1713,7 +1770,14 @@ fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
/// Format the AI Layout & Enforcement Spec as a structured Markdown document.
pub fn to_ai_spec_markdown(doc: &Document, root: NodeId, target: &str) -> Result<String, String> {
let spec = to_ai_spec(doc, root, target)?;
to_ai_spec_markdown_with_categories(doc, root, target, &crate::model::annotation::default_categories())
}
pub(crate) fn to_ai_spec_markdown_with_categories(
doc: &Document, root: NodeId, target: &str,
categories: &[crate::model::annotation::AnnotationCategory],
) -> Result<String, String> {
let spec = to_ai_spec_with_categories(doc, root, target, categories)?;
let name = spec["name"].as_str().unwrap_or("Frame");
let mut md = format!("# AI UI Implementation Spec: {name} ({target})\n\n");
+48 -1
View File
@@ -402,11 +402,21 @@ pub struct PageDiff {
pub groups: Vec<FrameGroup>,
/// Variable, mode, active-mode and token changes, as `name: value` pairs.
pub variables: Vec<PropChange>,
/// Added, removed and edited notes, paired by annotation id; each counts once like a variable.
pub annotations: Vec<AnnotationDiff>,
}
/// One note row in a [`PageDiff`]: `status` is "added", "removed" or "modified".
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct AnnotationDiff {
pub id: u64,
pub status: &'static str,
pub label: String,
}
impl PageDiff {
pub fn is_empty(&self) -> bool {
self.groups.is_empty() && self.variables.is_empty()
self.groups.is_empty() && self.variables.is_empty() && self.annotations.is_empty()
}
}
@@ -594,6 +604,7 @@ pub fn diff_pages(before: &Document, after: &Document) -> PageDiff {
modified: 0,
groups: Vec::new(),
variables: Vec::new(),
annotations: Vec::new(),
};
for &id in &after.root {
walk_page(before, after, id, &mut diff);
@@ -648,6 +659,24 @@ pub fn diff_pages(before: &Document, after: &Document) -> PageDiff {
&mut diff.variables,
);
diff.modified += diff.variables.len();
for a in &after.annotations {
let status = match before.annotations.iter().find(|b| b.id == a.id) {
None => "added",
Some(b) if b != a => "modified",
Some(_) => continue,
};
diff.annotations.push(AnnotationDiff { id: a.id, status, label: a.label.clone() });
}
for b in before.annotations.iter().filter(|b| !after.annotations.iter().any(|a| a.id == b.id)) {
diff.annotations.push(AnnotationDiff { id: b.id, status: "removed", label: b.label.clone() });
}
for a in &diff.annotations {
match a.status {
"added" => diff.added += 1,
"removed" => diff.removed += 1,
_ => diff.modified += 1,
}
}
diff
}
@@ -973,6 +1002,24 @@ mod tests {
(d, f, a, b)
}
#[test]
fn diff_pages_annotations_added_removed_modified() {
use crate::model::annotation::Annotation;
let mut before = Document::default();
let (mut keep, mut gone) = (Annotation::new(None, "keep", "rule"), Annotation::new(None, "gone", "rule"));
(keep.id, gone.id) = (1, 2);
before.annotations = vec![keep, gone];
let mut after = before.clone();
after.annotations.remove(1);
after.annotations[0].body = "edited".into();
let mut new = Annotation::new(None, "new", "rule");
new.id = 3;
after.annotations.push(new);
let p = diff_pages(&before, &after);
assert_eq!((p.added, p.removed, p.modified), (1, 1, 1));
assert!(diff_pages(&before, &before).is_empty());
}
#[test]
fn diff_pages_identical_is_empty() {
let (d, ..) = page();
+4
View File
@@ -337,6 +337,10 @@ pub fn verify_code(
let norm_target = normalize_target(target)?;
let is_egui = norm_target == "egui";
// Annotation comments describe rules, not rendered content or layout.
let source = code.lines().filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>().join("\n");
let code = source.as_str();
let visible_ids = visible_subtree(doc, root);
let mut violations: Vec<Violation> = Vec::new();
+329 -5
View File
@@ -289,6 +289,12 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"get_screenshot" => screenshot(host, &a),
"get_jsx" => jsx(host, &a),
"get_design_spec" => get_design_spec(ed, &a),
"annotate" => annotate(ed, &a),
"get_annotations" => get_annotations(ed, &a),
"get_annotation_template" => get_annotation_template(ed, &a),
"set_annotation_template" => set_annotation_template(ed, &a),
"update_annotation" => update_annotation(ed, &a),
"delete_annotations" => delete_annotations(ed, &a),
"verify_code" => verify_code(ed, &a),
"get_computed_styles" => computed_styles(ed, &a),
"get_fill_image" => fill_image(ed, &a),
@@ -1185,6 +1191,314 @@ fn guide(a: &Args) -> ToolResult {
Ok(vec![Out::Text(s.into())])
}
// ------------------------------------------------------------------------------ annotations
fn annotation_rows(rows: Vec<Value>) -> ToolResult {
Ok(vec![Out::Text(serde_json::to_string(&rows).unwrap())])
}
fn annotation_category(ed: &Editor, key: &str) -> Result<String, String> {
ed.design.category_by_key(key).map(|c| c.id).ok_or_else(|| {
format!(
"unknown annotation type `{key}`; valid keys: {}",
ed.design
.categories()
.iter()
.map(|c| c.key().to_string())
.collect::<Vec<_>>()
.join(", ")
)
})
}
fn annotation_node(ed: &Editor, key: &str, page: usize) -> Result<NodeId, String> {
if let Ok((p, id)) = resolve(ed, key, page) {
return if p == page {
Ok(id)
} else {
Err("all notes must be on the selected page".into())
};
}
let mut matches: Vec<_> = ed
.page_doc(page)
.nodes
.values()
.filter(|n| n.name.eq_ignore_ascii_case(key))
.map(|n| n.id)
.collect();
matches.sort_by_key(|id| id.0);
match matches.as_slice() {
[id] => Ok(*id),
[] => Err(format!("node `{key}` not found on page")),
_ => Err(format!(
"ambiguous node `{key}`; candidates: {}",
matches
.iter()
.take(5)
.map(|id| node_key(ed, page, *id))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
fn annotation_text(text: &str) -> (String, String) {
let (label, body) = text.split_once('\n').unwrap_or((text, ""));
(label.trim_end_matches('\r').into(), body.into())
}
/// The category id for type `key`, if the page's active template enables it.
fn enabled_category(ed: &Editor, page: usize, key: &str) -> Result<String, String> {
let category = annotation_category(ed, key)?;
let template = ed.active_template(page);
if template.enabled(key).is_none() {
return Err(format!(
"type `{key}` is not enabled by template `{}`; allowed: {}",
template.name,
template.enabled_keys().join(", ")
));
}
Ok(category)
}
fn annotate(ed: &mut Editor, a: &Args) -> ToolResult {
use crate::model::annotation::{Annotation, Author, auto_position, numbers};
let page = page_arg(ed, a)?;
let notes = a
.arr("notes")?
.iter()
.map(|v| {
let args = Args(v.as_object().ok_or("notes must be objects")?);
let node = annotation_node(ed, args.req_str("node")?, page)?;
let key = args
.get("type")
.map(|v| v.as_str().ok_or("type must be a string"))
.transpose()?
.unwrap_or("rule");
let category = enabled_category(ed, page, key)?;
let (label, body) = annotation_text(
args.get("text")
.and_then(Value::as_str)
.ok_or("missing text string")?,
);
let mut note = Annotation::new(Some(node), &label, &category);
note.body = body;
note.author = Author::Agent;
Ok(note)
})
.collect::<Result<Vec<_>, String>>()?;
let ids = ed.with_page(page, |ed| {
ed.edit("Annotate", |tx| {
notes
.into_iter()
.map(|mut note| {
note.position = auto_position(tx.doc(), note.target.unwrap());
tx.add_annotation(note)
})
.collect::<Vec<_>>()
})
});
let doc = ed.page_doc(page);
let numbers = numbers(doc);
let cats = ed.design.categories();
annotation_rows(
ids.iter()
.map(|id| {
let note = doc.annotations.iter().find(|n| n.id == *id).unwrap();
let key = cats
.iter()
.find(|c| c.id == note.category)
.map(|c| c.key())
.unwrap_or(&note.category);
json!([
numbers[id].1,
node_key(ed, page, note.target.unwrap()),
key,
note.label
])
})
.collect::<Vec<_>>(),
)
}
fn get_annotation_template(ed: &Editor, a: &Args) -> ToolResult {
let t = ed.active_template(page_arg(ed, a)?);
text(json!({
"template": t.name,
"types": t.categories.iter().filter(|c| c.enabled)
.map(|c| json!([c.key, c.prompt, c.fields])).collect::<Vec<_>>(),
}))
}
fn set_annotation_template(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let r = a.req_str("template")?;
let t = ed.design.template_by_ref(r).ok_or_else(|| {
format!(
"unknown template `{r}`; available: {}",
ed.design.templates().iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
)
})?;
match a.str("scope").unwrap_or("page") {
"page" => ed.set_page_template(page, Some(&t.id)),
"project" => ed.set_default_template(Some(&t.id)),
s => return Err(format!("scope must be page or project, got `{s}`")),
}
.map_err(|e| e.to_string())?;
text(json!({"template": t.name, "scope": a.str("scope").unwrap_or("page")}))
}
fn get_annotations(ed: &Editor, a: &Args) -> ToolResult {
let default_page = page_arg(ed, a)?;
let scope = a
.str("nodeId")
.map(|id| resolve(ed, id, default_page))
.transpose()?;
let page = scope.map_or(default_page, |s| s.0);
let doc = ed.page_doc(page);
let subtree = scope.map(|s| doc.subtree(s.1));
let numbers = crate::model::annotation::numbers(doc);
let cats = ed.design.categories();
let mut notes: Vec<_> = doc
.annotations
.iter()
.filter(|note| {
subtree
.as_ref()
.is_none_or(|ids| note.target.is_some_and(|id| ids.contains(&id)))
})
.collect();
notes.sort_by_key(|n| (numbers[&n.id].0.map(|id| id.0), numbers[&n.id].1));
annotation_rows(
notes
.into_iter()
.map(|note| {
let number = numbers[&note.id].1;
let key = cats
.iter()
.find(|c| c.id == note.category)
.map(|c| c.key())
.unwrap_or(&note.category);
if a.bool("full").unwrap_or(false) {
let mut value = serde_json::to_value(note).unwrap();
value["number"] = json!(number);
value["type"] = json!(key);
value["nodeId"] = json!(note.target.map(|id| node_key(ed, page, id)));
return value;
}
let name = note
.target
.and_then(|id| doc.get(id))
.map(|n| n.name.as_str())
.unwrap_or("Detached");
let text = if let Some(line) = note.body.lines().next() {
format!("{}\n{line}", note.label)
} else {
note.label.clone()
};
json!([
number,
name,
key,
text.chars().take(120).collect::<String>()
])
})
.collect::<Vec<_>>(),
)
}
fn annotation_ref(ed: &Editor, value: &Value, page: usize) -> Result<u64, String> {
let doc = ed.page_doc(page);
let key = value.as_str().map(str::trim);
if let Some(id) = value.as_u64().or_else(|| key.and_then(|s| s.parse().ok())) {
return doc
.annotations
.iter()
.find(|n| n.id == id)
.map(|n| n.id)
.ok_or_else(|| format!("annotation {id} not found"));
}
let (frame, number) = key
.and_then(|s| s.rsplit_once('#'))
.ok_or("expected annotation id or frame reference such as Sign in #3")?;
let number: u32 = number
.trim()
.parse()
.map_err(|_| "invalid annotation number")?;
let frame = frame.trim().strip_prefix("frame ").unwrap_or(frame.trim());
let numbers = crate::model::annotation::numbers(doc);
let matches: Vec<_> = doc
.annotations
.iter()
.filter(|n| {
let (root, num) = numbers[&n.id];
num == number
&& root
.and_then(|id| doc.get(id))
.is_some_and(|node| node.name.eq_ignore_ascii_case(frame))
})
.collect();
match matches.as_slice() {
[note] => Ok(note.id),
[] => Err(format!(
"annotation reference `{}` not found",
key.unwrap_or_default()
)),
_ => Err("ambiguous annotation reference; use an annotation id".into()),
}
}
fn update_annotation(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let id = annotation_ref(
ed,
a.get("id")
.or_else(|| a.get("ref"))
.ok_or("missing id or ref")?,
page,
)?;
let text_value = a
.get("text")
.map(|v| v.as_str().ok_or("text must be a string"))
.transpose()?;
let category = a
.get("type")
.map(|v| enabled_category(ed, page, v.as_str().ok_or("type must be a string")?))
.transpose()?;
ed.with_page(page, |ed| {
ed.update_annotation(id, |note| {
if let Some(text) = text_value {
(note.label, note.body) = annotation_text(text);
}
if let Some(category) = category {
note.category = category;
}
})
});
text(json!({"id": id}))
}
fn delete_annotations(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let values = a
.get("ids")
.or_else(|| a.get("refs"))
.and_then(Value::as_array)
.ok_or("missing ids or refs array")?;
let ids = values
.iter()
.map(|v| annotation_ref(ed, v, page))
.collect::<Result<Vec<_>, _>>()?;
ed.with_page(page, |ed| {
ed.edit("Delete annotations", |tx| {
for id in &ids {
tx.delete_annotation(*id);
}
})
});
text(json!({"deleted": ids}))
}
// ------------------------------------------------------------------------------ jsx
fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
@@ -1196,15 +1510,15 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
let doc = ed.page_doc(page);
match a.str("format") {
None | Some("") | Some("inline") | Some("inline-styles") => {
let mut out = String::new();
let mut out = super::codegen::annotation_comments(doc, id, &ed.design.categories());
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
Ok(vec![Out::Text(out)])
}
Some(
f @ ("tailwind" | "jsx" | "react" | "tsx" | "angular" | "ng" | "html" | "css" | "egui"
| "rust"),
) => Ok(vec![Out::Text(super::codegen::code_for_target(
doc, id, f,
) => Ok(vec![Out::Text(super::codegen::code_for_target_with_categories(
doc, id, f, &ed.design.categories(),
)?)]),
Some(f) => Err(format!(
"unknown format `{f}` (inline, tailwind, react, angular, html, or egui)"
@@ -1218,11 +1532,11 @@ fn get_design_spec(ed: &mut Editor, a: &Args) -> ToolResult {
let target = a.str("target").filter(|s| !s.is_empty()).unwrap_or("react");
match a.str("format").unwrap_or("markdown") {
"" | "markdown" | "md" => {
let md = super::codegen::to_ai_spec_markdown(doc, id, target)?;
let md = super::codegen::to_ai_spec_markdown_with_categories(doc, id, target, &ed.design.categories())?;
Ok(vec![Out::Text(md)])
}
"json" => {
let spec = super::codegen::to_ai_spec(doc, id, target)?;
let spec = super::codegen::to_ai_spec_with_categories(doc, id, target, &ed.design.categories())?;
text(spec)
}
other => Err(format!(
@@ -2823,6 +3137,13 @@ fn clone_subtree(
(map[&src], pairs)
}
/// Copy the annotations on a duplicated subtree, retargeted to the new nodes.
fn copy_annotations(tx: &mut Tx, from: &[crate::model::annotation::Annotation], pairs: &[(NodeId, NodeId)]) {
let map: HashMap<NodeId, NodeId> = pairs.iter().copied().collect();
let copies = crate::model::annotation::copies_for(from, &map);
tx.add_annotation_copies(copies);
}
/// A free spot for a new top-level frame: right of the existing artboards, 80 px apart.
fn free_spot(doc: &Document, w: f64) -> V2 {
let rects: Vec<Rect> = doc
@@ -4619,6 +4940,8 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
V2::ZERO
};
let r = clone_subtree(tx, id, target, index, offset);
let from = tx.doc().annotations.clone();
copy_annotations(tx, &from, &r.1);
relayout(tx, Some(r.0));
r
})
@@ -4636,6 +4959,7 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
-w.min()
};
let r = clone_subtree_from_doc(tx, &src_doc, id, target, None, offset);
copy_annotations(tx, &src_doc.annotations, &r.1);
relayout(tx, target.or(Some(r.0)));
r
})
+18 -2
View File
@@ -87,6 +87,22 @@ pub fn definitions() -> Vec<Value> {
let node_id = |d: &str| json!({"type": "string", "description": d});
let ids = |d: &str| json!({"type": "array", "items": {"type": "string"}, "description": d});
vec![
tool("annotate", "Add notes to node ids or exact layer names on a page in one undo step. First text line is label; remaining lines are body. Type is an allowed category key (default rule); put fields as \"Name: value\" lines in text. Placement is automatic. Returns [number, node, type, label] rows.",
json!({"pageId": page_id(), "notes": {"type": "array", "items": {
"type": "object", "properties": {"node": {"type": "string"},
"text": {"type": "string"}, "type": {"type": "string"}}, "required": ["node", "text"]}}}), &["notes"], false),
tool("get_annotation_template", "Read the page's annotation template: [type key, guidance, field names] for each allowed type. Call once before annotate.",
json!({"pageId": page_id()}), &[], true),
tool("set_annotation_template", "Switch the annotation template by id or name, for one page or the whole project. Annotate only accepts the template's types.",
json!({"template": {"type": "string"}, "scope": {"type": "string", "enum": ["page", "project"]}, "pageId": page_id()}), &["template"], false),
tool("get_annotations", "Read annotations as [number, nodeName, type, text] rows. Text includes label and first body line, limited to 120 characters. Use full for complete objects, nodeId for a subtree, or pageId for another page.",
json!({"nodeId": {"type": "string"}, "pageId": page_id(), "full": {"type": "boolean"}}), &[], true),
tool("update_annotation", "Update annotation text or category key by id or frame reference such as Sign in #3. First text line is label; remaining lines are body. One undo step.",
json!({"id": {"type": ["integer", "string"]}, "ref": {"type": "string"},
"text": {"type": "string"}, "type": {"type": "string"}, "pageId": page_id()}), &[], false),
tool("delete_annotations", "Delete annotations by ids or frame references such as Sign in #3 in one undo step.",
json!({"ids": {"type": "array", "items": {"type": ["integer", "string"]}},
"refs": {"type": "array", "items": {"type": "string"}}, "pageId": page_id()}), &[], false),
// ------------------------------------------------------------ projects and pages
tool(
"list_resources",
@@ -900,14 +916,14 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
"list_resources" | "get_basic_info" | "get_selection" | "get_node_info" | "get_children"
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
| "get_computed_styles" | "get_fill_image" | "find_nodes" | "get_font_family_info"
| "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
| "diff_frames" | "publish_library" | "list_libraries" => false,
"select_nodes" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
| "create_file" | "finish_working_on_nodes" => false,
"lint_design" => args["fix"] == true,
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html"
| "create_shape" | "update_styles" | "set_text_content" | "rename_nodes"
| "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
| "annotate" | "set_annotation_template" | "update_annotation" | "delete_annotations" | "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
| "create_collection" | "delete_variables" | "bind_variable"
| "enable_library" | "update_library" | "disable_library"
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
+315
View File
@@ -0,0 +1,315 @@
//! Annotations: notes that explain a design, attached to a layer (or a point) with a leader line.
//!
//! Pure model. The anchor is relative to the target's rect and the note card is positioned
//! relative to the target's top-left, so moving or resizing the target keeps both attached.
//! Once the target is deleted the annotation is `detached` and `position` / `point` are world.
use super::document::Document;
use super::node::{Color, NodeId};
use crate::geom::V2;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
Top,
Right,
Bottom,
Left,
#[default]
Center,
}
/// Where the leader line ends: a side of the target's rect and a fraction `t` (0..1) along it.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct Anchor {
pub side: Side,
#[serde(default)]
pub t: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Author {
#[default]
User,
Agent,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Annotation {
pub id: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<NodeId>,
/// World point the line ends at when there is no target.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<V2>,
#[serde(default)]
pub anchor: Anchor,
pub label: String,
#[serde(default)]
pub body: String,
/// Category id (see [`AnnotationCategory`]).
#[serde(default)]
pub category: String,
/// Note card top-left: relative to the target's top-left when `target` is set, else world.
pub position: V2,
#[serde(default)]
pub author: Author,
/// Unix milliseconds.
#[serde(default)]
pub created: u64,
/// The target was deleted; the note stays where it was, with a warning badge.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub detached: bool,
}
impl Annotation {
pub fn new(target: Option<NodeId>, label: &str, category: &str) -> Annotation {
Annotation {
id: 0,
target,
point: None,
anchor: Anchor::default(),
label: label.into(),
body: String::new(),
category: category.into(),
position: V2::ZERO,
author: Author::User,
created: 0,
detached: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnnotationCategory {
pub id: String,
/// Short type key used by agents and the UI (`rule`, `a11y`, ...). Empty means the id.
#[serde(default)]
pub key: String,
pub name: String,
pub color: Color,
pub icon: String,
}
/// Id (and key) of the category new notes get when none is named.
pub const DEFAULT_CATEGORY: &str = "rule";
impl AnnotationCategory {
/// The type key (the id when none was set).
pub fn key(&self) -> &str {
if self.key.is_empty() { &self.id } else { &self.key }
}
}
/// Find a category by its type key (case-insensitive).
pub fn category_by_key<'a>(cats: &'a [AnnotationCategory], key: &str) -> Option<&'a AnnotationCategory> {
cats.iter().find(|c| c.key().eq_ignore_ascii_case(key))
}
/// Size the canvas gives a note card, used to stack automatically placed notes.
pub const CARD_W: f64 = 220.0;
pub const CARD_H: f64 = 72.0;
const CARD_GAP: f64 = 24.0;
/// Where a new note on `target` goes, as a `position` (relative to the target's top-left):
/// right of the target's top-level frame, level with the target, pushed down past any
/// existing note card so none overlap.
pub fn auto_position(doc: &Document, target: NodeId) -> V2 {
let Some(t) = doc.world_rect(target) else { return V2::ZERO };
let frame = frame_of(doc, target)
.and_then(|f| doc.world_rect(f))
.unwrap_or(t);
let x = frame.right() + CARD_GAP;
let mut y = t.y;
let cards: Vec<V2> = doc.annotations.iter().map(|a| card_world(doc, a)).collect();
while cards.iter().any(|c| {
x < c.x + CARD_W && c.x < x + CARD_W && y < c.y + CARD_H && c.y < y + CARD_H
}) {
y += CARD_H + CARD_GAP / 2.0;
}
V2 { x: x, y: y } - t.min()
}
/// The six built-in categories, with stable ids.
pub fn default_categories() -> Vec<AnnotationCategory> {
let c = |id: &str, name: &str, color: Color, icon: &str| AnnotationCategory {
id: id.into(),
key: id.into(),
name: name.into(),
color,
icon: icon.into(),
};
vec![
c("rule", "Business rule", Color::rgb(0x7c, 0x5c, 0xff), "scale"),
c("behaviour", "Behaviour", Color::rgb(0x2f, 0x80, 0xed), "zap"),
c("content", "Content", Color::rgb(0x1f, 0xa8, 0x7a), "type"),
c("data", "Data", Color::rgb(0xe0, 0x8a, 0x1e), "database"),
c("a11y", "Accessibility", Color::rgb(0xd9, 0x4f, 0x8a), "accessibility"),
c("dev", "Dev note", Color::rgb(0x6b, 0x72, 0x80), "code"),
]
}
// ------------------------------------------------------------------------------ templates
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Display {
#[default]
Full,
Badges,
}
/// One kind of note a template asks for: guidance for what it should say and optional
/// structured `Name: value` lines.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TemplateCategory {
pub key: String,
#[serde(default = "yes")]
pub enabled: bool,
#[serde(default)]
pub prompt: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnnotationTemplate {
pub id: String,
pub name: String,
pub categories: Vec<TemplateCategory>,
#[serde(default)]
pub display: Display,
}
impl AnnotationTemplate {
/// The enabled category with this type key (case-insensitive).
pub fn enabled(&self, key: &str) -> Option<&TemplateCategory> {
self.categories.iter().find(|c| c.enabled && c.key.eq_ignore_ascii_case(key))
}
/// Keys of the enabled categories, in order.
pub fn enabled_keys(&self) -> Vec<&str> {
self.categories.iter().filter(|c| c.enabled).map(|c| c.key.as_str()).collect()
}
}
/// Id of the template used when neither the project nor the page picks one.
pub const DEFAULT_TEMPLATE: &str = "product-spec";
fn default_prompt(key: &str) -> &'static str {
match key {
"rule" => "The business rule or constraint this part enforces.",
"behaviour" => "What happens on interaction: states, transitions, edge cases.",
"content" => "The copy here: meaning, tone and limits.",
"data" => "Where the data comes from, its format and what if it is missing.",
"dev" => "Implementation note for developers.",
"a11y" => "Accessibility requirement: role, label, focus order, contrast.",
_ => "",
}
}
/// The five built-in templates (editable copies are stored in `design.json`).
pub fn builtin_templates() -> Vec<AnnotationTemplate> {
let t = |id: &str, name: &str, cats: &[(&str, &[&str])]| AnnotationTemplate {
id: id.into(),
name: name.into(),
categories: cats
.iter()
.map(|(key, fields)| TemplateCategory {
key: (*key).into(),
enabled: true,
prompt: default_prompt(key).into(),
fields: fields.iter().map(|f| (*f).into()).collect(),
})
.collect(),
display: Display::Full,
};
vec![
t("product-spec", "Product spec", &[("rule", &[]), ("behaviour", &[]), ("content", &[]), ("data", &[])]),
t("dev-handoff", "Dev handoff", &[("behaviour", &[]), ("data", &[]), ("dev", &[]), ("a11y", &[])]),
t("accessibility-review", "Accessibility review", &[("a11y", &["Issue", "WCAG", "Fix"])]),
t("content-review", "Content review", &[("content", &["Tone", "Max length"])]),
t("minimal", "Minimal", &[("rule", &[])]),
]
}
/// The leader line's end in world space.
pub fn anchor_world(doc: &Document, a: &Annotation) -> V2 {
let Some(r) = a.target.and_then(|t| doc.world_rect(t)) else {
return a.point.unwrap_or(a.position);
};
let t = a.anchor.t.clamp(0.0, 1.0);
match a.anchor.side {
Side::Top => V2 { x: r.x + t * r.w, y: r.y },
Side::Bottom => V2 { x: r.x + t * r.w, y: r.y + r.h },
Side::Left => V2 { x: r.x, y: r.y + t * r.h },
Side::Right => V2 { x: r.x + r.w, y: r.y + t * r.h },
Side::Center => r.center(),
}
}
/// The note card's top-left in world space.
pub fn card_world(doc: &Document, a: &Annotation) -> V2 {
match a.target.and_then(|t| doc.world_rect(t)) {
Some(r) => r.min() + a.position,
None => a.position,
}
}
/// The top-level frame a node lives in (the node itself when it is top level).
pub fn frame_of(doc: &Document, id: NodeId) -> Option<NodeId> {
doc.nodes.get(&id)?;
Some(doc.ancestors(id).last().copied().unwrap_or(id))
}
/// Number of every annotation, per top-level frame in reading order (target's world y, then x).
/// Annotations without a frame come last, numbered globally from 1, with frame `None`.
pub fn numbers(doc: &Document) -> HashMap<u64, (Option<NodeId>, u32)> {
let mut keyed: Vec<(Option<NodeId>, V2, u64)> = doc
.annotations
.iter()
.map(|a| {
let frame = a.target.and_then(|t| frame_of(doc, t));
let at = a
.target
.and_then(|t| doc.world_rect(t))
.map_or_else(|| anchor_world(doc, a), |r| r.min());
(frame, at, a.id)
})
.collect();
keyed.sort_by(|a, b| {
(a.0.is_none(), a.1.y, a.1.x, a.2)
.partial_cmp(&(b.0.is_none(), b.1.y, b.1.x, b.2))
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut count: HashMap<Option<NodeId>, u32> = HashMap::new();
let mut out = HashMap::new();
for (frame, _, id) in keyed {
let n = count.entry(frame).or_insert(0);
*n += 1;
out.insert(id, (frame, *n));
}
out
}
/// Copies of the annotations whose target is a key of `map`, retargeted to its value. Ids are
/// unset (`Tx::add_annotation_copies` allocates them).
pub fn copies_for(
annotations: &[Annotation],
map: &HashMap<NodeId, NodeId>,
) -> Vec<Annotation> {
annotations
.iter()
.filter_map(|a| {
let t = map.get(&a.target?)?;
Some(Annotation { target: Some(*t), ..a.clone() })
})
.collect()
}
+5
View File
@@ -1,5 +1,6 @@
//! The document arena: nodes keyed by id plus ordered child lists.
use super::annotation::Annotation;
use super::node::{Color, Node, NodeId};
use crate::geom::{Rect, V2};
use serde::{Deserialize, Serialize};
@@ -25,6 +26,9 @@ pub struct Document {
/// substituted for `var(--name)` when styles are written.
#[serde(default)]
pub tokens: BTreeMap<String, String>,
/// Notes that explain the design. Absent from page files when empty.
#[serde(default)]
pub annotations: Vec<Annotation>,
}
impl Default for Document {
@@ -38,6 +42,7 @@ impl Default for Document {
modes: BTreeMap::new(),
mode: None,
tokens: BTreeMap::new(),
annotations: Vec::new(),
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod annotation;
pub mod document;
pub mod node;
pub mod paint;
+50 -3
View File
@@ -6,7 +6,8 @@
//! unit-testable on its own.
use crate::geom::Camera;
use crate::variables::PageModes;
use crate::variables::{PAGE_TEMPLATE_KEY, PageModes};
use crate::model::annotation::Annotation;
use crate::model::{Color, Document, Node, NodeId};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -149,6 +150,11 @@ struct PageFile {
/// Active mode id per collection id.
#[serde(default, rename = "collectionModes", skip_serializing_if = "BTreeMap::is_empty")]
modes_by_collection: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<Annotation>,
/// This page's annotation template (overrides the project default).
#[serde(default, rename = "annotationTemplate", skip_serializing_if = "Option::is_none")]
annotation_template: Option<String>,
}
/// Page migrations, indexed by source version: `PAGE_STEPS[0]` turns v1 into v2.
@@ -206,7 +212,9 @@ pub fn page_to_json_modes(p: &PageData, modes: &PageModes) -> String {
modes: p.doc.modes.clone(),
mode: p.doc.mode.clone(),
tokens: p.doc.tokens.clone(),
modes_by_collection: modes.clone(),
modes_by_collection: modes.iter().filter(|(k, _)| *k != PAGE_TEMPLATE_KEY).map(|(k, v)| (k.clone(), v.clone())).collect(),
annotations: p.doc.annotations.clone(),
annotation_template: modes.get(PAGE_TEMPLATE_KEY).cloned(),
};
serde_json::to_string_pretty(&f).expect("page serializes")
}
@@ -220,7 +228,10 @@ pub fn page_from_json(s: &str) -> Result<PageData, FormatError> {
pub fn page_from_json_modes(s: &str) -> Result<(PageData, PageModes), FormatError> {
let v: Value = serde_json::from_str(s).map_err(FormatError::Json)?;
let v = migrate(v, PAGE_FORMAT, PAGE_VERSION, PAGE_ALIASES, PAGE_STEPS)?;
let f: PageFile = serde_json::from_value(v).map_err(FormatError::Json)?;
let mut f: PageFile = serde_json::from_value(v).map_err(FormatError::Json)?;
if let Some(t) = f.annotation_template.take() {
f.modes_by_collection.insert(PAGE_TEMPLATE_KEY.into(), t);
}
let max_id = f.nodes.iter().map(|n| n.id.0).max().unwrap_or(0);
let nodes: HashMap<NodeId, Node> = f.nodes.into_iter().map(|n| (n.id, n)).collect();
let doc = Document {
@@ -232,6 +243,7 @@ pub fn page_from_json_modes(s: &str) -> Result<(PageData, PageModes), FormatErro
modes: f.modes,
mode: f.mode,
tokens: f.tokens,
annotations: f.annotations,
};
doc.validate().map_err(FormatError::Invalid)?;
Ok((
@@ -322,6 +334,41 @@ mod tests {
assert_eq!(page_to_json(&back), s);
}
fn page_with(doc: Document) -> PageData {
PageData { id: "abc".into(), name: "Plan".into(), doc, camera: None }
}
#[test]
fn annotations_roundtrip_and_old_pages_stay_byte_identical() {
use crate::model::annotation::{Annotation, Anchor, Side};
let ed = demo::showcase();
let plain = page_to_json(&page_with(Document { name: "Plan".into(), ..ed.doc.clone() }));
assert!(!plain.contains("annotations"), "empty list is not written");
// An old file (no field) loads and saves to the same bytes.
let old = page_from_json(&plain).unwrap();
assert!(old.doc.annotations.is_empty());
assert_eq!(page_to_json(&old), plain);
let mut doc = Document { name: "Plan".into(), ..ed.doc.clone() };
let mut a = Annotation::new(doc.root.first().copied(), "Rule", "business-rule");
a.id = 77;
a.body = "**bold**".into();
a.anchor = Anchor { side: Side::Top, t: 0.5 };
a.position = crate::geom::v2(12.0, -30.0);
a.created = 1234;
doc.annotations.push(a);
let mut d = Annotation::new(None, "Gone", "data");
d.id = 78;
d.point = Some(crate::geom::v2(1.0, 2.0));
d.detached = true;
doc.annotations.push(d);
let p = page_with(doc);
let s = page_to_json(&p);
let back = page_from_json(&s).unwrap();
assert_eq!(back, p);
assert_eq!(page_to_json(&back), s);
}
#[test]
fn legacy_v1_file_migrates_to_page() {
let ed = demo::showcase();
+22
View File
@@ -1092,6 +1092,28 @@ mod tests {
t.call(h, "write_html", &args, now).unwrap();
}
#[test]
fn annotation_only_run_is_kept_counted_reverted_and_undone() {
let (mut h, mut t) = (host(), Tracker::default());
write(&mut t, &mut h, T0);
t.tick(T0 + GAP + 1, Some(&dir_of(&h)), &h.ed);
t.take_finished().unwrap();
let node = h.ed.doc.root[0].0.to_string();
let notes = json!({"notes": [{"node": node, "text": "Rule one"}, {"node": node, "text": "Rule two"}]});
t.call(&mut h, "annotate", &notes, T0 + GAP + 2).unwrap();
assert_eq!(h.ed.doc.annotations.len(), 2);
assert_eq!(t.counts(&h.ed), Some((2, 0, 0, false)));
t.tick(T0 + 2 * GAP + 3, Some(&dir_of(&h)), &h.ed);
let mut run = t.take_finished().expect("annotation-only run kept");
assert_eq!((run.added, run.changed, run.removed), (2, 0, 0));
assert_eq!(run.state, RunState::Review);
let with_notes = h.ed.doc.annotations.clone();
revert(&dir_of(&h), &mut h.ed, &mut run).unwrap();
assert!(h.ed.doc.annotations.is_empty());
h.ed.undo();
assert_eq!(h.ed.doc.annotations, with_notes);
}
#[test]
fn target_pages_resolves_nested_sources_and_destinations() {
let mut ed = Editor::default();
+96
View File
@@ -652,6 +652,50 @@ pub enum Icon {
Laser,
Library,
Sketch,
Note,
Scale,
Database,
Accessibility,
Code,
Zap,
}
/// The app's painted icon for a Lucide name, when it has one.
#[must_use]
pub fn icon_by_name(name: &str) -> Option<Icon> {
Some(match name {
"chevron-right" => Icon::ChevronRight,
"chevron-down" => Icon::ChevronDown,
"chevron-up" => Icon::ChevronUp,
"plus" => Icon::Plus,
"minus" => Icon::Minus,
"trash-2" => Icon::Trash,
"lock" => Icon::Lock,
"unlock" => Icon::LockOpen,
"eye" => Icon::Eye,
"eye-off" => Icon::EyeSlash,
"panel-left" => Icon::Sidebar,
"hand" => Icon::Hand,
"mouse-pointer-2" => Icon::Pointer,
"pencil" => Icon::Pencil,
"pen" => Icon::Pen,
"eraser" => Icon::Eraser,
"type" => Icon::Text,
"square" => Icon::Rect,
"circle" => Icon::Ellipse,
"image" => Icon::Image,
"star" => Icon::Star,
"scale" => Icon::Scale,
"zap" => Icon::Zap,
"database" => Icon::Database,
"accessibility" => Icon::Accessibility,
"code" => Icon::Code,
"grid-3x3" => Icon::Grid,
"ellipsis" => Icon::More,
"download" => Icon::Export,
"circle-question-mark" => Icon::Question,
_ => return None,
})
}
pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color32) {
@@ -999,6 +1043,58 @@ pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color3
]);
line(&[pt(13.5, 9.5), pt(9.5, 9.5), pt(9.5, 13.5)]);
}
Icon::Note => {
closed(&[
pt(2.5, 2.5),
pt(13.5, 2.5),
pt(13.5, 10.5),
pt(8.0, 10.5),
pt(5.0, 13.5),
pt(5.0, 10.5),
pt(2.5, 10.5),
]);
line(&[pt(5.0, 5.5), pt(11.0, 5.5)]);
line(&[pt(5.0, 7.8), pt(9.0, 7.8)]);
}
Icon::Scale => {
line(&[pt(8.0, 2.0), pt(8.0, 14.0)]);
line(&[pt(4.7, 14.0), pt(11.3, 14.0)]);
line(&[pt(2.0, 4.7), pt(3.3, 4.7), pt(8.0, 3.3), pt(12.7, 4.7), pt(14.0, 4.7)]);
for x in [3.3, 12.7] {
line(&[pt(x - 2.0, 10.7), pt(x, 5.3), pt(x + 2.0, 10.7)]);
painter.add(egui::Shape::line(arc(pt(x, 10.7), 2.0 * u, 0.0, std::f32::consts::PI, 10), st));
}
}
Icon::Database => {
let ell = |cy: f32, a0: f32, a1: f32| -> Vec<Pos2> {
(0..=16)
.map(|i| {
let a = a0 + (a1 - a0) * i as f32 / 16.0;
pt(8.0 + 5.3 * a.cos(), cy + 2.0 * a.sin())
})
.collect()
};
closed(&ell(4.0, 0.0, std::f32::consts::TAU - 0.4));
line(&ell(8.0, 0.0, std::f32::consts::PI));
line(&ell(12.0, 0.0, std::f32::consts::PI));
line(&[pt(2.7, 4.0), pt(2.7, 12.0)]);
line(&[pt(13.3, 4.0), pt(13.3, 12.0)]);
}
Icon::Accessibility => {
painter.circle_stroke(c, 6.5 * u, st);
painter.circle_filled(pt(8.0, 4.9), 0.9 * u, color);
line(&[pt(4.8, 6.9), pt(11.2, 6.9)]);
line(&[pt(8.0, 6.9), pt(8.0, 9.6)]);
line(&[pt(6.4, 12.0), pt(8.0, 9.6), pt(9.6, 12.0)]);
}
Icon::Code => {
line(&[pt(5.3, 4.7), pt(2.0, 8.0), pt(5.3, 11.3)]);
line(&[pt(10.7, 4.7), pt(14.0, 8.0), pt(10.7, 11.3)]);
line(&[pt(9.3, 3.0), pt(6.7, 13.0)]);
}
Icon::Zap => {
closed(&[pt(9.2, 1.5), pt(3.0, 8.8), pt(7.6, 8.8), pt(6.8, 14.5), pt(13.0, 7.2), pt(8.4, 7.2)]);
}
Icon::Diamond => {
closed(&[pt(8.0, 1.8), pt(14.2, 8.0), pt(8.0, 14.2), pt(1.8, 8.0)]);
}
File diff suppressed because it is too large Load Diff
+487
View File
@@ -0,0 +1,487 @@
//! Annotations on the canvas: note cards in screen space, elbow leader lines to their targets,
//! drag to move, click to select, and the view states (global mode, per-category, per-note).
//! Drawn as an egui overlay only, so exports and Present mode never include it.
use crate::editor::Editor;
use crate::editor::shortcuts;
use crate::geom::V2;
use crate::model::annotation::{
Annotation, AnnotationCategory, CARD_H, CARD_W, Display, Side, anchor_world, card_world, numbers,
};
use crate::theme::{self, Icon};
use egui::{Align2, Color32, Galley, Pos2, Rect, Sense, Stroke, vec2};
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
/// How much of each note the canvas shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Full,
Badges,
Hidden,
}
impl Mode {
pub fn next(self) -> Mode {
match self {
Mode::Full => Mode::Badges,
Mode::Badges => Mode::Hidden,
Mode::Hidden => Mode::Full,
}
}
}
impl From<Display> for Mode {
fn from(d: Display) -> Mode {
match d {
Display::Full => Mode::Full,
Display::Badges => Mode::Badges,
}
}
}
type GalleyKey = (u64, u64, u32, bool);
/// View state (never saved): global mode, hidden categories, collapsed notes, the drag in
/// progress and the text layout cache.
#[derive(Default)]
pub struct AnnotationsView {
/// `None` follows the page template's display.
pub mode: Option<Mode>,
pub hidden_categories: HashSet<String>,
pub collapsed: HashSet<u64>,
drag: Option<(u64, V2)>,
galleys: HashMap<GalleyKey, Arc<Galley>>,
nums: Option<(u64, Arc<Numbers>)>,
}
pub type Numbers = HashMap<u64, (Option<crate::model::NodeId>, u32)>;
impl AnnotationsView {
/// Note numbers of the active page, recomputed only when the document changed.
pub fn numbers(&mut self, ed: &Editor) -> Arc<Numbers> {
if let Some((stamp, n)) = &self.nums
&& *stamp == ed.doc_stamp
{
return n.clone();
}
let n = Arc::new(numbers(&ed.doc));
self.nums = Some((ed.doc_stamp, n.clone()));
n
}
pub fn effective(&self, template_display: Display) -> Mode {
self.mode.unwrap_or_else(|| template_display.into())
}
/// Full -> Badges -> Hidden -> Full.
pub fn cycle(&mut self, template_display: Display) {
self.mode = Some(self.effective(template_display).next());
}
}
const SCALE_MIN: f64 = 0.6;
const SCALE_MAX: f64 = 1.5;
const SCALE_STEP: f64 = 0.1;
const GALLEY_CACHE_MAX: usize = 512;
const PAD: f32 = 12.0;
const BADGE_R: f32 = 9.0;
const BADGE_ONLY: f32 = 22.0;
const LABEL_PT: f32 = 13.0;
const BODY_PT: f32 = 11.0;
const BODY_ROWS: usize = 2;
const ICON_PX: f32 = 14.0;
const RADIUS: f32 = 10.0;
const LINE_W: f32 = 1.5;
const DOT_R: f32 = 3.5;
const LINE_HIT: f32 = 5.0;
const OUTLINE_W: f32 = 2.0;
const OUTLINE_R: f32 = 4.0;
const PILL_LIFT: f32 = 72.0;
/// Card scale for a zoom: follows the zoom but stays readable, in coarse steps so the text
/// layout cache is not thrashed while zooming.
fn scale_bucket(zoom: f64) -> u32 {
(zoom.clamp(SCALE_MIN, SCALE_MAX) / SCALE_STEP).round() as u32
}
fn bucket_scale(b: u32) -> f32 {
(f64::from(b) * SCALE_STEP) as f32
}
/// Orthogonal leader line from a note card (or badge) rect to `anchor`: straight down/up when the
/// anchor is above or below the card, else out of the facing side with one elbow.
pub fn leader_route(card: Rect, anchor: Pos2) -> Vec<Pos2> {
if anchor.x >= card.left() && anchor.x <= card.right() {
let y = if anchor.y < card.top() { card.top() } else { card.bottom() };
return vec![Pos2::new(anchor.x, y), anchor];
}
let sx = if anchor.x > card.right() { card.right() } else { card.left() };
let s = Pos2::new(sx, card.center().y);
if (s.y - anchor.y).abs() < 0.5 {
return vec![s, anchor];
}
let mx = (s.x + anchor.x) / 2.0;
vec![s, Pos2::new(mx, s.y), Pos2::new(mx, anchor.y), anchor]
}
/// Point on `target`'s edge facing `from`: the side `from` lies beside, level with it (clamped).
fn edge_toward(target: Rect, from: Pos2) -> Pos2 {
if from.x > target.right() {
Pos2::new(target.right(), from.y.clamp(target.top(), target.bottom()))
} else if from.x < target.left() {
Pos2::new(target.left(), from.y.clamp(target.top(), target.bottom()))
} else {
let y = if from.y < target.top() { target.top() } else { target.bottom() };
Pos2::new(from.x, y)
}
}
fn near_line(pts: &[Pos2], p: Pos2) -> bool {
pts.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
let ab = b - a;
let len2 = ab.length_sq();
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
(a + ab * t).distance(p) <= LINE_HIT
})
}
fn c32(c: crate::model::Color) -> Color32 {
super::c32(c)
}
struct Item<'a> {
a: &'a Annotation,
n: u32,
color: Color32,
icon: Icon,
rect: Rect,
badge_only: bool,
route: Vec<Pos2>,
target: Option<Rect>,
}
fn text_hash(a: &Annotation) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
a.label.hash(&mut h);
a.body.hash(&mut h);
h.finish()
}
fn galley(
ui: &egui::Ui,
cache: &mut HashMap<GalleyKey, Arc<Galley>>,
key: GalleyKey,
text: &str,
font: egui::FontId,
wrap: f32,
rows: usize,
) -> Arc<Galley> {
if cache.len() > GALLEY_CACHE_MAX {
cache.clear();
}
cache
.entry(key)
.or_insert_with(|| {
let mut job = egui::text::LayoutJob::simple(text.to_owned(), font, Color32::WHITE, wrap);
job.wrap.max_rows = rows;
job.wrap.break_anywhere = false;
job.wrap.overflow_character = Some('…');
ui.fonts_mut(|f| f.layout_job(job))
})
.clone()
}
/// Draw the overlay and the bottom pill; apply a finished drag as one undo step.
pub fn draw(
ctx: &egui::Context,
ed: &mut Editor,
view: &mut AnnotationsView,
selected: &mut Option<u64>,
canvas: egui::Rect,
) {
if ed.doc.annotations.is_empty() {
return;
}
let mode = view.effective(ed.active_template(ed.active_page).display);
let mut commit: Option<(u64, V2)> = None;
if mode != Mode::Hidden {
commit = cards(ctx, ed, view, selected, canvas, mode);
}
pill(ctx, ed, view, canvas, mode);
if let Some((id, off)) = commit {
ed.update_annotation(id, |a| a.position = a.position + off);
}
}
fn cards(
ctx: &egui::Context,
ed: &Editor,
view: &mut AnnotationsView,
selected: &mut Option<u64>,
canvas: Rect,
mode: Mode,
) -> Option<(u64, V2)> {
let cam = ed.camera;
let bucket = scale_bucket(cam.zoom);
let s = bucket_scale(bucket);
let cats: Vec<AnnotationCategory> = ed.design.categories();
let nums = view.numbers(ed);
let to_screen = |w: V2| {
let v = cam.world_to_screen(w);
canvas.min + vec2(v.x as f32, v.y as f32)
};
let drag = view.drag;
let mut items: Vec<Item> = Vec::new();
for a in &ed.doc.annotations {
if view.hidden_categories.contains(&a.category) {
continue;
}
let cat = cats.iter().find(|c| c.id == a.category).or(cats.first());
let (color, icon) = cat.map_or((Color32::GRAY, Icon::Sticky), |c| {
(c32(c.color), theme::icon_by_name(&c.icon).unwrap_or(Icon::Sticky))
});
let mut at = card_world(&ed.doc, a);
if let Some((id, off)) = drag.filter(|(id, _)| *id == a.id) {
let _ = id;
at = at + off;
}
let badge_only = mode == Mode::Badges || view.collapsed.contains(&a.id);
let size = if badge_only {
vec2(BADGE_ONLY * s, BADGE_ONLY * s)
} else {
vec2(CARD_W as f32 * s, CARD_H as f32 * s)
};
let rect = Rect::from_min_size(to_screen(at), size);
let lineless = a.detached || (a.target.is_none() && a.point.is_none());
let target = a.target.and_then(|t| ed.doc.world_rect(t)).map(|r| {
let r = cam.world_rect_to_screen(&r);
Rect::from_min_size(
canvas.min + vec2(r.x as f32, r.y as f32),
vec2(r.w as f32, r.h as f32),
)
});
let mut anchor = to_screen(anchor_world(&ed.doc, a));
if a.anchor.side == Side::Center
&& let Some(t) = target
{
anchor = edge_toward(t, rect.center());
}
let route = if lineless { Vec::new() } else { leader_route(rect, anchor) };
let bounds = route.iter().fold(rect, |r, p| r.union(Rect::from_min_max(*p, *p)));
if !canvas.intersects(bounds) {
continue;
}
items.push(Item {
a,
n: nums.get(&a.id).map_or(0, |n| n.1),
color,
icon,
rect,
badge_only,
route,
target,
});
}
let mut commit = None;
let pointer = ctx.pointer_hover_pos();
egui::Area::new("annotations-overlay".into())
.order(egui::Order::Middle)
.fade_in(false)
.fixed_pos(canvas.min)
.show(ctx, |ui| {
ui.set_clip_rect(canvas);
let p = theme::pal(ui);
let painter = ui.painter().clone();
for it in &items {
if it.route.len() < 2 {
continue;
}
painter.add(egui::Shape::line(it.route.clone(), Stroke::new(LINE_W, it.color)));
painter.circle_filled(*it.route.last().unwrap(), DOT_R, it.color);
}
for it in &items {
let a = it.a;
let resp = ui.interact(it.rect, egui::Id::new(("annotation-card", a.id)), Sense::click_and_drag());
let badge_rect = Rect::from_center_size(
if it.badge_only {
it.rect.center()
} else {
it.rect.min + vec2(PAD + BADGE_R, PAD + BADGE_R) * s
},
vec2(BADGE_R * 2.0, BADGE_R * 2.0) * s,
);
let badge = ui.interact(badge_rect, egui::Id::new(("annotation-badge", a.id)), Sense::click());
let on_line = pointer.is_some_and(|ptr| near_line(&it.route, ptr));
let is_sel = *selected == Some(a.id);
if (resp.hovered() || on_line || is_sel)
&& let Some(t) = it.target
{
painter.rect_stroke(t, OUTLINE_R, Stroke::new(OUTLINE_W, it.color), egui::StrokeKind::Outside);
}
if resp.hovered() || on_line {
ctx.set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::PointingHand });
}
if !it.badge_only {
painter.rect_filled(it.rect, RADIUS * s, p.popover);
let stroke = if is_sel {
Stroke::new(LINE_W, it.color)
} else {
Stroke::new(1.0, p.separator)
};
painter.rect_stroke(it.rect, RADIUS * s, stroke, egui::StrokeKind::Inside);
let th = text_hash(a);
let text_x = badge_rect.right() + 8.0 * s;
let icon_rect = Rect::from_center_size(
Pos2::new(it.rect.right() - (PAD + ICON_PX / 2.0) * s, badge_rect.center().y),
vec2(ICON_PX, ICON_PX) * s,
);
let label_w = (icon_rect.left() - 6.0 * s - text_x).max(10.0);
let lg = galley(ui, &mut view.galleys, (a.id, th, bucket, true), &a.label,
theme::semibold(LABEL_PT * s), label_w, 1);
painter.galley_with_override_text_color(
Pos2::new(text_x, badge_rect.center().y - lg.size().y / 2.0), lg, p.text);
theme::paint_icon(&painter, icon_rect, it.icon, it.color);
if !a.body.is_empty() {
let bw = it.rect.width() - PAD * 2.0 * s;
let bg = galley(ui, &mut view.galleys, (a.id, th, bucket, false), &a.body,
egui::FontId::proportional(BODY_PT * s), bw, BODY_ROWS);
painter.galley_with_override_text_color(
Pos2::new(it.rect.left() + PAD * s, badge_rect.bottom() + 6.0 * s), bg, p.secondary);
}
}
let detached = a.detached || (a.target.is_none() && a.point.is_none());
let fill = if detached { p.warning } else { it.color };
painter.circle_filled(badge_rect.center(), BADGE_R * s, fill);
let mark = if detached { "!".to_owned() } else { it.n.to_string() };
painter.text(
badge_rect.center(),
Align2::CENTER_CENTER,
mark,
theme::semibold(BODY_PT * s),
super::kit::ink_on(fill),
);
if badge.clicked() {
*selected = Some(a.id);
if mode == Mode::Full && !view.collapsed.remove(&a.id) {
view.collapsed.insert(a.id);
}
} else if resp.clicked() || resp.drag_started() {
*selected = Some(a.id);
}
if resp.dragged() {
let prev = view.drag.filter(|(id, _)| *id == a.id).map_or(V2::ZERO, |d| d.1);
let d = resp.drag_delta();
view.drag = Some((a.id, prev + V2 { x: f64::from(d.x), y: f64::from(d.y) } / cam.zoom));
}
if resp.drag_stopped()
&& let Some((id, off)) = view.drag.take()
&& off != V2::ZERO
{
commit = Some((id, off));
}
}
});
commit
}
fn pill(ctx: &egui::Context, ed: &Editor, view: &mut AnnotationsView, canvas: Rect, mode: Mode) {
let chord = shortcuts::chord_text(shortcuts::Action::ToggleAnnotations);
let text = if mode == Mode::Hidden {
format!("Annotations off · {chord}")
} else {
format!("Annotations on · {} · {chord}", ed.doc.annotations.len())
};
let display = ed.active_template(ed.active_page).display;
egui::Area::new("annotations-pill".into())
.order(egui::Order::Middle)
.fade_in(false)
.pivot(Align2::CENTER_BOTTOM)
.fixed_pos(Pos2::new(canvas.center().x, canvas.bottom() - PILL_LIFT))
.show(ctx, |ui| {
let p = theme::pal(ui);
let r = egui::Frame::new()
.fill(p.popover)
.corner_radius(egui::CornerRadius::same(255))
.inner_margin(egui::Margin::symmetric(14, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
theme::paint_icon(ui.painter(), ir, Icon::Page, p.secondary);
ui.add(egui::Label::new(
egui::RichText::new(text).font(theme::semibold(theme::SECONDARY)).color(p.text),
).selectable(false));
});
});
if ui.interact(r.response.rect, egui::Id::new("annotations-pill-click"), Sense::click()).clicked() {
view.cycle(display);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cycle_full_badges_hidden() {
let mut v = AnnotationsView::default();
assert_eq!(v.effective(Display::Full), Mode::Full);
v.cycle(Display::Full);
assert_eq!(v.effective(Display::Full), Mode::Badges);
v.cycle(Display::Full);
assert_eq!(v.effective(Display::Full), Mode::Hidden);
v.cycle(Display::Full);
assert_eq!(v.effective(Display::Full), Mode::Full);
// Template default Badges starts there.
let mut v = AnnotationsView::default();
assert_eq!(v.effective(Display::Badges), Mode::Badges);
v.cycle(Display::Badges);
assert_eq!(v.effective(Display::Badges), Mode::Hidden);
}
#[test]
fn route_shapes() {
let card = Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 40.0));
// Anchor right of the card, different y: H-V-H elbow from the right edge.
let r = leader_route(card, Pos2::new(300.0, 100.0));
assert_eq!(r.len(), 4);
assert_eq!(r[0], Pos2::new(100.0, 20.0));
assert_eq!(r[3], Pos2::new(300.0, 100.0));
assert_eq!(r[1].y, r[0].y);
assert_eq!(r[2].x, r[1].x);
assert_eq!(r[2].y, 100.0);
// Level anchor: straight.
assert_eq!(leader_route(card, Pos2::new(300.0, 20.0)).len(), 2);
// Left of the card: leaves the left edge.
assert_eq!(leader_route(card, Pos2::new(-50.0, 90.0))[0].x, 0.0);
// Below the card: vertical from the bottom.
let r = leader_route(card, Pos2::new(40.0, 200.0));
assert_eq!(r, vec![Pos2::new(40.0, 40.0), Pos2::new(40.0, 200.0)]);
}
#[test]
fn scale_is_bucketed_and_clamped() {
assert_eq!(scale_bucket(0.01), scale_bucket(0.6));
assert_eq!(scale_bucket(50.0), scale_bucket(1.5));
assert_eq!(scale_bucket(1.0), 10);
}
#[test]
fn edge_anchor_faces_card() {
let t = Rect::from_min_size(Pos2::new(100.0, 100.0), vec2(200.0, 50.0));
assert_eq!(edge_toward(t, Pos2::new(400.0, 500.0)), Pos2::new(300.0, 150.0));
assert_eq!(edge_toward(t, Pos2::new(0.0, 120.0)), Pos2::new(100.0, 120.0));
assert_eq!(edge_toward(t, Pos2::new(150.0, 0.0)), Pos2::new(150.0, 100.0));
}
#[test]
fn line_hit_test() {
let pts = [Pos2::new(0.0, 0.0), Pos2::new(100.0, 0.0)];
assert!(near_line(&pts, Pos2::new(50.0, 3.0)));
assert!(!near_line(&pts, Pos2::new(50.0, 20.0)));
}
}
+382 -264
View File
@@ -6,7 +6,11 @@
//! theme choice), not in the project folder, so they do not sync to other people.
use super::chat_msgs::{self, Acts, View};
#[path = "chat_agents.rs"]
pub mod chat_agents;
use chat_agents::{Act, PanelIn};
use crate::chat::events::{Event, ImageAttachment};
use crate::chat::agents::{self, AgentKind, AgentState, AgentStatus};
use crate::chat::{Backend, BackendKind, Chat, Message, Role, ToolCall, store};
use crate::project::format::{new_id, now_ms};
use crate::theme::{self, Icon};
@@ -34,8 +38,6 @@ pub use crate::chat::TMP_DIR;
/// A run that started this long before the turn's first edit call or after its last still counts as its own
/// (events reach the UI a frame after the tool ran).
const RUN_SLACK_MS: u64 = 5_000;
const CLAUDE_INSTALL: &str = "npm install -g @anthropic-ai/claude-code";
const CODEX_INSTALL: &str = "npm install -g @openai/codex";
const REPAINT: Duration = Duration::from_millis(50);
#[cfg(not(target_arch = "wasm32"))]
const TITLE_CHARS: usize = 40;
@@ -47,6 +49,8 @@ const WEB_NOTE: &str = "Chat needs the desktop app";
pub enum TurnHandle {
Cli(Box<dyn crate::chat::events::Turn>),
#[cfg(not(target_arch = "wasm32"))]
Acp(crate::chat::acp::AcpTurn),
#[cfg(not(target_arch = "wasm32"))]
Api(crate::chat::tools::ApiTurn),
}
@@ -55,6 +59,8 @@ impl TurnHandle {
match self {
TurnHandle::Cli(t) => t.stop(),
#[cfg(not(target_arch = "wasm32"))]
TurnHandle::Acp(t) => crate::chat::events::Turn::stop(t),
#[cfg(not(target_arch = "wasm32"))]
TurnHandle::Api(t) => crate::chat::events::Turn::stop(t),
}
}
@@ -63,15 +69,19 @@ impl TurnHandle {
match self {
TurnHandle::Cli(t) => t.is_running(),
#[cfg(not(target_arch = "wasm32"))]
TurnHandle::Acp(t) => crate::chat::events::Turn::is_running(t),
#[cfg(not(target_arch = "wasm32"))]
TurnHandle::Api(t) => crate::chat::events::Turn::is_running(t),
}
}
/// Answer a `NeedsApproval` event (API backend only).
/// Answer a `NeedsApproval` event (API and ACP backends).
pub fn approve(&self, id: &str, ok: bool) {
#[cfg(not(target_arch = "wasm32"))]
if let TurnHandle::Api(t) = self {
t.approve(id, ok);
match self {
TurnHandle::Api(t) => t.approve(id, ok),
TurnHandle::Acp(t) => t.approve(id, ok),
TurnHandle::Cli(_) => {}
}
#[cfg(target_arch = "wasm32")]
let _ = (id, ok);
@@ -97,6 +107,8 @@ pub struct Detect {
pub codex: Option<Found>,
pub endpoints: Vec<EndpointState>,
pub default_endpoint: String,
/// Every registry and custom agent (`agents::detect_all`).
pub agents: Vec<AgentStatus>,
}
impl Detect {
@@ -105,9 +117,31 @@ impl Detect {
BackendKind::ClaudeCode => self.claude.is_some(),
BackendKind::Codex => self.codex.is_some(),
BackendKind::Anthropic => self.endpoints.iter().any(|e| e.ready),
BackendKind::Acp => self.first_acp().is_some(),
}
}
/// The state of one agent. Claude Code and Codex use their own detection, which is what
/// `has` and the turn use too.
pub fn agent_state(&self, id: &str) -> AgentState {
let direct = |f: &Option<Found>| f.as_ref().map_or(AgentState::NotFound, |(p, v)| AgentState::Ready { path: p.display().to_string(), version: v.clone() });
match id {
"claude-code" => direct(&self.claude),
"codex" => direct(&self.codex),
_ => self.agents.iter().find(|s| s.id == id).map_or(AgentState::NotFound, |s| s.state.clone()),
}
}
pub fn agent_ready(&self, id: &str) -> bool {
matches!(self.agent_state(id), AgentState::Ready { .. })
}
/// The first ready agent that runs over ACP.
pub fn first_acp(&self) -> Option<&str> {
let acp = |s: &&AgentStatus| matches!(s.state, AgentState::Ready { .. }) && agents::find(&s.id).is_none_or(|d| d.kind != AgentKind::Direct);
self.agents.iter().find(acp).map(|s| s.id.as_str())
}
/// The endpoint with this id (None = the default one) can run.
pub fn endpoint_ready(&self, id: Option<&str>) -> bool {
let id = id.unwrap_or(&self.default_endpoint);
@@ -117,6 +151,7 @@ impl Detect {
pub fn has_backend(&self, b: &Backend) -> bool {
match b.kind {
BackendKind::Anthropic => self.endpoint_ready(b.endpoint.as_deref()),
BackendKind::Acp => b.agent.as_deref().is_some_and(|id| self.agent_ready(id)),
k => self.has(k),
}
}
@@ -128,12 +163,12 @@ impl Detect {
}
pub fn any(&self) -> bool {
[BackendKind::ClaudeCode, BackendKind::Codex, BackendKind::Anthropic].iter().any(|k| self.has(*k))
[BackendKind::ClaudeCode, BackendKind::Codex, BackendKind::Anthropic, BackendKind::Acp].iter().any(|k| self.has(*k))
}
/// The default backend when available, else the first one that is.
pub fn pick(&self, preferred: BackendKind) -> BackendKind {
[preferred, BackendKind::ClaudeCode, BackendKind::Codex, BackendKind::Anthropic]
[preferred, BackendKind::ClaudeCode, BackendKind::Codex, BackendKind::Anthropic, BackendKind::Acp]
.into_iter()
.find(|k| self.has(*k))
.unwrap_or(preferred)
@@ -153,10 +188,11 @@ fn scan(prefs: &super::settings::ChatPrefs) -> Detect {
})
.collect();
Detect {
claude: claude_code::detect(prefs.claude_path.as_deref().map(Path::new)),
codex: codex::detect(prefs.codex_path.as_deref().map(Path::new)),
claude: claude_code::detect(cli_path(prefs, "claude-code").map(Path::new)),
codex: codex::detect(cli_path(prefs, "codex").map(Path::new)),
endpoints,
default_endpoint: prefs.default_endpoint.clone(),
agents: agents::detect_all(prefs),
}
}
@@ -226,6 +262,11 @@ pub struct ChatState {
pub thought_secs: Option<u64>,
/// The last turn's error, shown as a row with Retry.
pub error: Option<String>,
/// `error` is a failed start (nothing ran): Retry sends again what was typed.
pub start_failed: bool,
/// The prompt of the failed start, for Retry when the composer was cleared (suggestion chips).
#[cfg(not(target_arch = "wasm32"))]
failed_prompt: Option<String>,
/// Project the state belongs to; a change resets it.
pub project: Option<PathBuf>,
pub project_name: String,
@@ -240,11 +281,15 @@ pub struct ChatState {
/// Copy of the settings' chat prefs (endpoints), refreshed each frame, for `send`.
pub prefs: super::settings::ChatPrefs,
detect_rx: Option<Receiver<Detect>>,
/// The agent picker is open.
/// The agent panel is open.
pub picker_open: bool,
panel: chat_agents::PanelState,
/// Composer text, selection chip and images.
pub draft: String,
pub sel: Option<SelChip>,
/// One chip per `@ Selection` click, oldest first.
pub sels: Vec<SelChip>,
/// Annotation lines of the chips' layers, refreshed from the editor just before a send.
pub sel_notes: Vec<String>,
pub images: Vec<Attachment>,
/// Text of the title field while renaming.
pub renaming: Option<String>,
@@ -588,7 +633,7 @@ impl ChatState {
chat.title = text.lines().next().unwrap_or_default().chars().take(TITLE_CHARS).collect();
}
let turn_chat = chat.id.clone();
let Backend { kind, model, effort, endpoint, cli_session } = chat.backend.clone();
let Backend { kind, model, effort, endpoint, cli_session, agent } = chat.backend.clone();
let ep = self.prefs.endpoint(endpoint.as_deref()).clone();
// Another host's model is whatever the endpoint says now; the Claude host keeps the chat's pick.
let model = if ep.is_claude_host() { model.or_else(|| Some(ep.model.clone())) } else { Some(ep.model.clone()) };
@@ -604,6 +649,7 @@ impl ChatState {
tools: self.tools.clone(),
images: std::mem::take(&mut self.pending_images),
system,
binary: cli_binary(&self.prefs, kind),
..Default::default()
};
let (tx, rx) = std::sync::mpsc::channel();
@@ -615,8 +661,9 @@ impl ChatState {
ApiFormat::OpenAi => openai::start(&cfg, prompt, tx),
}
.map(TurnHandle::Api),
BackendKind::Acp => start_acp(&cfg, &self.prefs, agent.as_deref(), prompt, tx),
};
self.error = None;
(self.error, self.start_failed, self.failed_prompt) = (None, false, None);
match started {
Ok(t) => {
self.thinking.clear();
@@ -635,7 +682,7 @@ impl ChatState {
if let Some(c) = self.chat.as_mut() {
c.messages.pop();
}
self.error = Some(e.clone());
(self.error, self.start_failed, self.failed_prompt) = (Some(e.clone()), true, Some(prompt.to_owned()));
Err(e)
}
}
@@ -700,12 +747,56 @@ impl ChatState {
effort: ep.filter(|e| e.is_claude_host()).map(|_| prefs.api_effort.clone()),
endpoint: ep.map(|e| e.id.clone()),
cli_session: None,
agent: (kind == BackendKind::Acp).then(|| d.first_acp().map(str::to_owned)).flatten(),
};
}
/// Signed-out agent: the error is its authentication error.
pub fn signin(&self) -> Option<chat_agents::SignIn> {
let e = self.error.as_deref()?;
let b = &self.chat.as_ref()?.backend;
let id = b.agent.as_deref().filter(|_| b.kind == BackendKind::Acp)?;
chat_agents::signin_for(id, &agent_name(id, &self.prefs), e)
}
/// Gives the open chat the backend of a panel row or a model change; another target starts a
/// new agent session.
fn set_backend(&mut self, b: Backend) {
let running = self.is_running();
let Some(c) = self.chat.as_mut().filter(|_| !running) else { return };
let keep = chat_agents::same_target(&c.backend, &b);
c.backend = Backend { cli_session: if keep { c.backend.cli_session.clone() } else { None }, ..b };
if !c.messages.is_empty() {
self.save();
}
}
/// What the user did in the panel or the setup card.
fn act(&mut self, ctx: &egui::Context, act: Act, settings: &mut super::settings::SettingsUi) {
match act {
Act::Select(pick) => {
self.set_backend(chat_agents::backend_for(&pick, &settings.chat));
self.picker_open = self.is_running();
}
Act::SetBackend(b) => self.set_backend(b),
Act::Enable(id) => {
settings.chat.agents.entry(id).or_default().enabled = true;
settings.chat.save();
self.rescan(ctx, &settings.chat);
}
Act::Settings => {
settings.open_chat();
self.picker_open = false;
}
Act::DetectAgain => self.rescan(ctx, &settings.chat),
Act::OpenPanel => (self.picker_open, self.panel.fresh) = (true, true),
Act::Close => self.picker_open = false,
}
}
fn reset_view(&mut self) {
self.thinking.clear();
(self.thinking_since, self.thought_secs, self.error) = (None, None, None);
(self.thinking_since, self.thought_secs, self.error, self.start_failed) = (None, None, None, false);
self.renaming = None;
}
@@ -760,9 +851,29 @@ impl ChatState {
}
/// Sends the composer: context lines (selection, images), then the text. API images go as
/// blocks; CLI images are saved under the project and referenced by path.
/// blocks; CLI images are saved under the project and referenced by path. Any failure is
/// kept in `error` (an error row) and leaves the composer as it was.
#[cfg(not(target_arch = "wasm32"))]
pub fn submit(&mut self, ctx: &egui::Context) -> Result<(), String> {
let r = self.submit_inner(ctx);
if let Err(e) = &r {
(self.error, self.start_failed) = (Some(e.clone()), true);
}
r
}
/// Retry on a failed start: what is typed, else the prompt that failed.
#[cfg(not(target_arch = "wasm32"))]
pub fn retry_start(&mut self, ctx: &egui::Context) {
if !self.draft.trim().is_empty() {
let _ = self.submit(ctx);
} else if let Some(p) = self.failed_prompt.clone() {
let _ = self.send(ctx, &p);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn submit_inner(&mut self, ctx: &egui::Context) -> Result<(), String> {
let text = self.draft.trim().to_owned();
if text.is_empty() {
return Ok(());
@@ -772,10 +883,7 @@ impl ChatState {
return Err("Open a project to chat".into());
};
let (api, chat_id) = (chat.backend.kind == BackendKind::Anthropic, chat.id.clone());
let mut lines = Vec::new();
if let Some(s) = &self.sel {
lines.push(format!("[Selection] {} ({})", s.label, s.keys.join(", ")));
}
let mut lines = selection_lines(&self.sels, &self.sel_notes);
let mut pending = Vec::new();
for a in &self.images {
if api {
@@ -793,7 +901,7 @@ impl ChatState {
let r = self.send(ctx, &prompt);
self.pending_images.clear();
if r.is_ok() {
(self.sel, self.images) = (None, Vec::new());
(self.sels, self.images) = (Vec::new(), Vec::new());
self.draft.clear();
}
r
@@ -804,6 +912,9 @@ impl ChatState {
Err(WEB_NOTE.into())
}
#[cfg(target_arch = "wasm32")]
pub fn retry_start(&mut self, _ctx: &egui::Context) {}
#[cfg(target_arch = "wasm32")]
pub fn send(&mut self, _ctx: &egui::Context, _prompt: &str) -> Result<(), String> {
Err(WEB_NOTE.into())
@@ -823,9 +934,37 @@ impl ChatState {
}
}
/// "Not found at /opt/x/claude" when a path is set in Settings, else "Not found on PATH".
fn not_found(custom: Option<&str>) -> String {
custom.map_or_else(|| "Not found on PATH".to_owned(), |p| format!("Not found at {p}"))
/// Starts the chat's ACP agent: its launch command from the registry (or a custom agent).
#[cfg(not(target_arch = "wasm32"))]
fn start_acp(cfg: &crate::chat::events::TurnConfig, prefs: &super::settings::ChatPrefs, agent: Option<&str>, prompt: &str, tx: std::sync::mpsc::Sender<Event>) -> Result<TurnHandle, String> {
let id = agent.ok_or("No agent is chosen for this chat. Pick one from the agent chip.")?;
let name = agent_name(id, prefs);
let (program, args) = agents::launch_command(id, prefs).ok_or_else(|| format!("{name} is not available. Check it in the agent panel (Not found, or off until enabled)."))?;
crate::chat::acp::start(cfg, &program, &args, prompt, tx).map(TurnHandle::Acp)
}
/// The name an agent shows under: the registry's, a custom agent's, else its id.
fn agent_name(id: &str, prefs: &super::settings::ChatPrefs) -> String {
prefs.custom_agents.iter().find(|c| c.id == id).map(|c| c.name.clone()).or_else(|| agents::find(id).map(|a| a.name.to_owned())).unwrap_or_else(|| id.to_owned())
}
/// The path Settings holds for a direct agent: its `agents` entry, else the old field.
#[cfg(not(target_arch = "wasm32"))]
fn cli_path<'a>(prefs: &'a super::settings::ChatPrefs, id: &str) -> Option<&'a str> {
let old = if id == "codex" { prefs.codex_path.as_deref() } else { prefs.claude_path.as_deref() };
prefs.agents.get(id).and_then(|a| a.path.as_deref()).or(old)
}
/// The CLI path set in Settings; detection uses it, so a turn must too (None = look on PATH).
#[cfg(not(target_arch = "wasm32"))]
fn cli_binary(prefs: &super::settings::ChatPrefs, kind: BackendKind) -> Option<PathBuf> {
match kind {
BackendKind::ClaudeCode => cli_path(prefs, "claude-code"),
BackendKind::Codex => cli_path(prefs, "codex"),
BackendKind::Anthropic | BackendKind::Acp => None,
}
.filter(|p| !p.trim().is_empty())
.map(PathBuf::from)
}
fn model_name(id: &str) -> &str {
@@ -836,6 +975,7 @@ fn backend_label(b: &Backend, prefs: &super::settings::ChatPrefs) -> String {
match b.kind {
BackendKind::ClaudeCode => "Claude Code".into(),
BackendKind::Codex => "Codex".into(),
BackendKind::Acp => b.agent.as_deref().map_or("Agent".into(), |id| agent_name(id, prefs)),
BackendKind::Anthropic => {
let ep = prefs.endpoint(b.endpoint.as_deref());
if ep.is_claude_host() {
@@ -852,7 +992,54 @@ fn fmt_tokens(n: u64) -> String {
if n >= 1000 { format!("{}k", n / 1000) } else { n.to_string() }
}
/// The current selection becomes a chip (Cmd+L or the @ button).
/// "Card" for one layer, "Card \u{b7} 3 layers" for several.
fn chip_label(first: &str, count: usize) -> String {
if count > 1 { format!("{first} \u{b7} {count} layers") } else { first.to_owned() }
}
/// Adds a chip unless one with the same layers is already there.
fn push_chip(chips: &mut Vec<SelChip>, chip: SelChip) {
if !chips.iter().any(|c| c.keys == chip.keys) {
chips.push(chip);
}
}
/// The `[Selection]` context lines of the chips, one each, with node ids.
#[cfg(not(target_arch = "wasm32"))]
fn selection_lines(chips: &[SelChip], notes: &[String]) -> Vec<String> {
chips.iter().map(|s| format!("[Selection] {} ({})", s.label, s.keys.join(", "))).chain(notes.iter().map(|n| format!("[Selection] {n}"))).collect()
}
/// One line per annotation on the chips' layers (and their children): number, type key, label,
/// first line of the body.
pub fn selection_notes(ed: &Editor, chips: &[SelChip]) -> Vec<String> {
let cats = ed.design.categories();
let mut seen = Vec::new();
let mut out = Vec::new();
for key in chips.iter().flat_map(|c| &c.keys) {
let Some((prefix, num)) = key.split_once(':') else { continue };
let (Ok(page), Ok(n)) = (crate::mcp::exec::find_page(ed, prefix), num.parse::<u64>()) else { continue };
let doc = ed.page_doc(page);
let id = crate::model::NodeId(n);
if doc.get(id).is_none() {
continue;
}
let (subtree, numbers) = (doc.subtree(id), crate::model::annotation::numbers(doc));
for a in doc.annotations.iter().filter(|a| a.target.is_some_and(|t| subtree.contains(&t))) {
if seen.contains(&(page, a.id)) {
continue;
}
seen.push((page, a.id));
let type_key = crate::model::annotation::category_by_key(&cats, &a.category).or_else(|| cats.iter().find(|c| c.id == a.category)).map_or(a.category.as_str(), |c| c.key());
let line = a.body.lines().next().unwrap_or_default().trim();
let sep = if line.is_empty() { "" } else { " \u{2014} " };
out.push(format!("note #{} [{type_key}] on {key}: {}{sep}{line}", numbers.get(&a.id).map_or(0, |n| n.1), a.label));
}
}
out
}
/// The current selection becomes a chip (Cmd+L or the @ button); each click adds one.
fn add_selection(ed: &mut Editor, st: &mut ChatState) {
let ids = ed.top_selection();
let Some(first) = ids.first() else {
@@ -862,8 +1049,7 @@ fn add_selection(ed: &mut Editor, st: &mut ChatState) {
let page = ed.active_page;
let keys = ids.iter().map(|id| crate::mcp::exec::node_key(ed, page, *id)).collect();
let name = ed.doc.get(*first).map(|n| n.name.clone()).unwrap_or_default();
let label = if ids.len() > 1 { format!("{name} +{}", ids.len() - 1) } else { name };
st.sel = Some(SelChip { label, keys });
push_chip(&mut st.sels, SelChip { label: chip_label(&name, ids.len()), keys });
}
/// Pans the canvas to a node the agent touched, unless the user moved the camera lately.
@@ -989,22 +1175,33 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.spacing_mut().item_spacing.y = 6.0;
let chip = header(ui, st);
ui.add_space(6.0);
let setup = st.chat.detect.as_ref().is_some_and(|d| !d.any());
// Nothing to talk to yet: no agent is ready, or this empty chat's agent is gone.
let setup = st.chat.detect.as_ref().zip(st.chat.chat.as_ref()).is_some_and(|(d, c)| c.messages.is_empty() && !d.has_backend(&c.backend));
let mut acts = Acts::default();
egui::Panel::bottom("chat-composer").frame(egui::Frame::NONE).resizable(false).show(ui, |ui| {
let composer_top = egui::Panel::bottom("chat-composer").frame(egui::Frame::NONE).resizable(false).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 6.0;
if let Some(todos) = st.chat.chat.as_ref().map(chat_msgs::latest_plan).filter(|t| !t.is_empty()) {
chat_msgs::plan_card(ui, &todos);
}
composer(ui, ed, st, column_rect, setup);
});
})
.response
.rect
.top();
if st.review_run.is_some() {
super::panels::review(ui, ed, st);
} else if setup {
setup_view(ui, st);
let act = egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| chat_agents::setup(ui, st.chat.detect.as_ref().expect("setup needs a scan"), &st.settings.chat)).inner;
if let Some(a) = act {
st.chat.act(&ctx, a, &mut st.settings);
}
} else if st.chat.chat.as_ref().is_none_or(|c| c.messages.is_empty()) && !st.chat.is_running() {
chat_msgs::empty(ui, &mut acts);
if let Some(e) = st.chat.error.as_deref() {
chat_msgs::error_row(ui, e, st.chat.signin().as_ref(), &mut acts);
}
} else if let Some(chat) = &st.chat.chat {
let signin = st.chat.signin();
let view = View {
running: st.chat.is_running(),
thinking: &st.chat.thinking,
@@ -1012,11 +1209,16 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
runs: &st.runs,
approvals: &st.chat.approvals,
error: st.chat.error.as_deref(),
signin: signin.as_ref(),
};
chat_msgs::list(ui, chat, &view, &mut acts);
}
if let Some(chip) = chip {
picker(&ctx, st, chip);
if let Some(chip) = chip.filter(|_| st.chat.picker_open) {
let (d, backend) = (st.chat.detect.clone().unwrap_or_default(), st.chat.chat.as_ref().map(|c| c.backend.clone()).unwrap_or_default());
let input = PanelIn { detect: &d, prefs: &st.settings.chat, backend: &backend, running: st.chat.is_running(), column: column_rect, chip, composer_top };
if let Some(a) = chat_agents::panel(&ctx, &mut st.chat.panel, &input) {
st.chat.act(&ctx, a, &mut st.settings);
}
}
apply_acts(&ctx, ed, st, acts);
if let Some(dir) = st.project_dir.clone() {
@@ -1058,6 +1260,11 @@ fn apply_acts(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, acts: Acts
if let Some(id) = acts.accept {
super::panels::accept_run(ed, st, &id);
}
if acts.retry_last && st.chat.start_failed {
st.chat.sel_notes = selection_notes(ed, &st.chat.sels);
st.chat.retry_start(ctx);
return;
}
let again = acts.retry_last.then(|| st.chat.last_prompt()).flatten();
if let Some(text) = acts.send.or(again) {
let _ = st.chat.send(ctx, &text);
@@ -1096,7 +1303,7 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
});
});
let label = match (&st.chat.chat, &st.chat.detect) {
(_, Some(d)) if !d.any() => "No agent connected".to_owned(),
(Some(c), Some(d)) if !d.has_backend(&c.backend) => "No agent connected".to_owned(),
(Some(c), _) => backend_label(&c.backend, &st.settings.chat),
_ => String::new(),
};
@@ -1107,6 +1314,7 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
rect = Some(r.rect);
if r.clicked() {
st.chat.picker_open = !st.chat.picker_open;
st.chat.panel.fresh = st.chat.picker_open;
}
let Some((used, window)) = context else { return };
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -1161,225 +1369,6 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
});
}
fn section_label(ui: &mut Ui, text: &str) {
ui.add_space(4.0);
ui.label(RichText::new(text).font(theme::semibold(theme::SECONDARY)).color(theme::pal(ui).tertiary));
}
/// One agent in the picker: name, status line, optional install hint, state chip. Click selects.
fn agent_row(ui: &mut Ui, name: &str, sub: &str, hint: Option<&str>, found: bool, selected: bool) -> egui::Response {
let p = theme::pal(ui);
let r = egui::Frame::new()
.fill(if selected { p.field } else { egui::Color32::TRANSPARENT })
.corner_radius(theme::RADIUS_LG)
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
let ink = if found { p.text } else { p.tertiary };
ui.label(RichText::new(name).font(theme::semibold(theme::BODY)).color(ink));
ui.add(egui::Label::new(RichText::new(sub).size(theme::SECONDARY).color(p.secondary)).truncate());
if let Some(h) = hint {
ui.label(RichText::new(h).monospace().size(theme::SECONDARY).color(theme::accent()));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if found {
kit::chip_ink(ui, if selected { "Selected" } else { "Found" }, Some(p.success));
} else {
kit::chip_ink(ui, "Missing", None);
}
});
});
})
.response;
ui.interact(r.rect, r.id.with("pick"), egui::Sense::click())
}
fn picker(ctx: &egui::Context, st: &mut UiState, chip: egui::Rect) {
if !st.chat.picker_open {
return;
}
let p = theme::pal_from_ctx(ctx);
let running = st.chat.is_running();
let detect = st.chat.detect.clone().unwrap_or_default();
let backend = st.chat.chat.as_ref().map(|c| c.backend.clone()).unwrap_or_default();
let prefs = st.settings.chat.clone();
let picker_w = st.chat.column_width() - 2.0 * 14.0;
let mut choose: Option<Backend> = None;
let mut open_settings = false;
let mut again = false;
let area = egui::Area::new("chat-picker".into())
.order(egui::Order::Foreground)
.fade_in(false)
.fixed_pos(chip.left_bottom() + egui::vec2(0.0, 6.0))
.show(ctx, |ui| {
egui::Frame::new()
.fill(p.popover)
.stroke(egui::Stroke::new(1.0, p.separator))
.corner_radius(theme::RADIUS_PANEL)
.inner_margin(10)
.show(ui, |ui| {
ui.set_width(picker_w - 20.0);
ui.spacing_mut().item_spacing.y = 2.0;
section_label(ui, "LOCAL AGENTS");
for (kind, name, found, install, custom) in [
(BackendKind::ClaudeCode, "Claude Code", &detect.claude, CLAUDE_INSTALL, &prefs.claude_path),
(BackendKind::Codex, "Codex", &detect.codex, CODEX_INSTALL, &prefs.codex_path),
] {
let (sub, hint) = match found {
Some((path, v)) => (format!("Found \u{b7} v{v} \u{b7} {}", path.display()), None),
None => (not_found(custom.as_deref()), Some(install)),
};
let r = agent_row(ui, name, &sub, hint, found.is_some(), backend.kind == kind);
if r.clicked() && found.is_some() && !running {
choose = Some(Backend { kind, ..Default::default() });
}
if r.clicked() && found.is_none() {
ui.ctx().copy_text(install.to_owned());
}
}
section_label(ui, "API ENDPOINTS");
let current = backend.endpoint.clone().unwrap_or_else(|| prefs.default_endpoint.clone());
for ep in &prefs.endpoints {
let state = detect.endpoints.iter().find(|s| s.id == ep.id);
let ready = state.is_some_and(|s| s.ready);
let host = crate::chat::endpoint::host(&ep.base_url).unwrap_or_else(|| ep.base_url.clone());
let sub = match state {
_ if !ep.is_complete() => "Set the URL and model in Settings, Chat".to_string(),
Some(s) if s.key => format!("{host} \u{b7} {} \u{b7} key set", ep.model),
Some(s) if ep.needs_key() && !s.key => "No key. Add one in Settings, Chat".to_string(),
_ => format!("{host} \u{b7} {}", ep.model),
};
let on = backend.kind == BackendKind::Anthropic && current == ep.id;
if agent_row(ui, &ep.name, &sub, None, ready, on).clicked() && ready && !running {
choose = Some(Backend {
kind: BackendKind::Anthropic,
model: Some(ep.model.clone()),
effort: ep.is_claude_host().then(|| prefs.api_effort.clone()),
endpoint: Some(ep.id.clone()),
cli_session: None,
});
}
}
let selected = prefs.endpoint(backend.endpoint.as_deref());
if backend.kind == BackendKind::Anthropic && selected.is_claude_host() {
let model = backend.model.clone().unwrap_or_else(|| selected.model.clone());
section_label(ui, "MODEL");
for (id, label) in super::settings::API_MODELS {
let on = model == id;
let r = ui.add(egui::Button::selectable(on, RichText::new(format!("{label} {id}")).size(theme::SECONDARY + 1.0)).frame(false));
if r.clicked() && !running {
choose = Some(Backend { model: Some(id.into()), ..backend.clone() });
}
}
section_label(ui, "EFFORT");
let effort = backend.effort.clone().unwrap_or_else(|| prefs.api_effort.clone());
let items: Vec<(&str, Option<&str>)> = super::settings::API_EFFORTS.iter().map(|(_, l)| (*l, None)).collect();
let cur = super::settings::API_EFFORTS.iter().position(|(id, _)| *id == effort).unwrap_or(1);
if let Some(i) = kit::segmented(ui, &items, cur)
&& !running
{
choose = Some(Backend { effort: Some(super::settings::API_EFFORTS[i].0.into()), ..backend.clone() });
}
}
ui.add_space(6.0);
ui.horizontal(|ui| {
again = kit::chip(ui, "Detect again", false).clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
open_settings = kit::chip_ink(ui, "Settings \u{2192} Chat", Some(theme::accent())).clicked();
});
});
});
});
if let Some(b) = choose
&& let Some(c) = st.chat.chat.as_mut()
{
c.backend = Backend { cli_session: if b.kind == c.backend.kind { c.backend.cli_session.clone() } else { None }, ..b };
if !c.messages.is_empty() {
st.chat.save();
}
st.chat.picker_open = running;
}
if again {
st.chat.rescan(ctx, &prefs);
}
if open_settings {
st.settings.open_chat();
st.chat.picker_open = false;
}
let outside = ctx.input(|i| i.pointer.any_pressed()) && ctx.pointer_interact_pos().is_some_and(|pos| !area.response.rect.contains(pos) && !chip.contains(pos));
if outside {
st.chat.picker_open = false;
}
}
fn setup_view(ui: &mut Ui, st: &mut UiState) {
let p = theme::pal(ui);
let ctx = ui.ctx().clone();
ui.add_space(ui.available_height() * 0.08);
kit::badge(ui, Icon::Laser, p.warning, 40.0);
ui.add_space(6.0);
ui.label(RichText::new("Connect an agent to start").font(theme::semibold(18.0)));
ui.add(egui::Label::new(RichText::new("Chat needs a local agent CLI or an API key. Pick one, it takes a minute.").color(p.secondary)));
ui.add_space(8.0);
let step = |ui: &mut Ui, n: u8, title: &str, body: &str, add: &mut dyn FnMut(&mut Ui)| {
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_LG).inner_margin(12).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(n.to_string()).font(theme::semibold(theme::BODY)).color(theme::accent()));
ui.label(RichText::new(title).font(theme::semibold(theme::BODY)));
});
ui.label(RichText::new(body).size(theme::SECONDARY).color(p.secondary));
add(ui);
});
};
step(ui, 1, "Install a local agent", "Uses your own login. PaperCraft passes no keys.", &mut |ui| {
for cmd in [CLAUDE_INSTALL, CODEX_INSTALL] {
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_MD).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(cmd).monospace().size(theme::SECONDARY).color(theme::accent()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if kit::lottie_button(ui, "copy", "Copy", 22.0).clicked() {
ui.ctx().copy_text(cmd.to_owned());
}
});
});
});
}
});
#[cfg(not(target_arch = "wasm32"))]
step(ui, 2, "Or add an API key", "For the default endpoint. Stored in the system keychain, never in the project. More endpoints are in Settings.", &mut |ui| {
ui.add(egui::TextEdit::singleline(&mut st.chat.key_draft).password(true).margin(theme::INPUT_MARGIN).hint_text("sk-ant-\u{2026}").desired_width(f32::INFINITY));
if let Some(e) = &st.chat.key_error {
ui.label(RichText::new(e).size(theme::SECONDARY).color(p.danger));
}
if kit::button(ui, kit::ButtonKind::Primary, None, "Save key").clicked() && !st.chat.key_draft.trim().is_empty() {
match crate::chat::endpoint::set_key(st.settings.chat.default_endpoint(), &st.chat.key_draft) {
Ok(()) => {
st.chat.key_draft.clear();
st.chat.key_error = None;
st.chat.rescan(&ctx, &st.settings.chat);
}
Err(e) => st.chat.key_error = Some(e),
}
}
});
step(ui, 3, "Check again", "PaperCraft looks for claude and codex on your PATH.", &mut |ui| {
ui.horizontal(|ui| {
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Restore), "Detect again").clicked() {
st.chat.rescan(&ctx, &st.settings.chat);
}
if kit::button(ui, kit::ButtonKind::Secondary, None, "Open settings").clicked() {
st.settings.open_chat();
}
});
});
}
#[cfg(not(target_arch = "wasm32"))]
fn image_mime(name: &str) -> Option<&'static str> {
let ext = name.rsplit('.').next()?.to_ascii_lowercase();
@@ -1419,6 +1408,31 @@ fn clipboard_png() -> Option<Vec<u8>> {
Some(out.into_inner())
}
/// One selection chip: "@ Name 3 layers x". True when clicked (removes it).
fn sel_chip(ui: &mut Ui, label: &str) -> bool {
let p = theme::pal(ui);
let (name, rest) = label.split_once(" \u{b7} ").unwrap_or((label, ""));
let paint = |text: &str, font: egui::FontId, color| ui.painter().layout_no_wrap(text.to_owned(), font, color);
let parts = [
paint("@", theme::semibold(theme::SECONDARY), theme::accent()),
paint(name, theme::semibold(theme::SECONDARY), p.text),
paint(rest, egui::FontId::proportional(theme::SECONDARY), p.secondary),
paint("\u{d7}", egui::FontId::proportional(theme::BODY), p.secondary),
];
const GAP: f32 = 6.0;
let shown = if rest.is_empty() { &parts[..2] } else { &parts[..3] };
let w = 2.0 * GAP + shown.iter().chain([&parts[3]]).map(|g| g.size().x + GAP).sum::<f32>();
let (rect, resp) = ui.allocate_exact_size(egui::vec2(w, kit::CHIP_H), egui::Sense::click());
ui.painter().rect_filled(rect, egui::CornerRadius::same(255), theme::blend(p.field, p.hover.gamma_multiply(f32::from(u8::from(resp.hovered())))));
let mut x = rect.left() + GAP + 2.0;
for g in shown.iter().chain([&parts[3]]) {
let pos = egui::pos2(x, rect.center().y - g.size().y / 2.0);
x += g.size().x + GAP;
ui.painter().galley(pos, g.clone(), p.text);
}
resp.on_hover_text("Remove from the chat").clicked()
}
fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect, setup: bool) {
let p = theme::pal(ui);
let no_project = st.project_dir.is_none();
@@ -1438,12 +1452,11 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_PANEL).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
if st.chat.sel.is_some() || !st.chat.images.is_empty() {
if !st.chat.sels.is_empty() || !st.chat.images.is_empty() {
ui.horizontal_wrapped(|ui| {
if let Some(s) = &st.chat.sel
&& kit::chip_ink(ui, &format!("@ Selection \u{b7} {} \u{d7}", s.label), Some(theme::accent())).clicked()
{
st.chat.sel = None;
let clicked = st.chat.sels.iter().position(|s| sel_chip(ui, &s.label));
if let Some(i) = clicked {
st.chat.sels.remove(i);
}
let mut drop = None;
for (i, a) in st.chat.images.iter().enumerate() {
@@ -1508,6 +1521,7 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
});
if (submit || enter) && !running && backend_ok && !no_project {
let ctx = ui.ctx().clone();
st.chat.sel_notes = selection_notes(ed, &st.chat.sels);
let _ = st.chat.submit(&ctx);
}
}
@@ -1688,12 +1702,6 @@ mod tests {
assert_eq!(st.context_use(), Some((CONTEXT_WINDOW, CONTEXT_WINDOW)));
}
#[test]
fn missing_agent_wording_names_the_custom_path() {
assert_eq!(not_found(None), "Not found on PATH");
assert_eq!(not_found(Some("/opt/x/claude")), "Not found at /opt/x/claude");
}
#[test]
fn error_ends_the_turn_and_is_kept() {
let mut st = ChatState { chat: Some(new_chat()), ..Default::default() };
@@ -1796,4 +1804,114 @@ mod tests {
follow(&mut ed, &mut c);
assert_eq!(ed.camera.pan, V2::ZERO);
}
fn chip(label: &str, keys: &[&str]) -> SelChip {
SelChip { label: label.into(), keys: keys.iter().map(|k| (*k).into()).collect() }
}
#[test]
fn selections_stack_dedupe_and_become_context_lines() {
assert_eq!(chip_label("Card", 1), "Card");
assert_eq!(chip_label("Card", 3), "Card \u{b7} 3 layers");
let mut chips = vec![];
push_chip(&mut chips, chip("Card", &["a:1"]));
push_chip(&mut chips, chip("Hero \u{b7} 2 layers", &["a:2", "a:3"]));
push_chip(&mut chips, chip("Card", &["a:1"]));
assert_eq!(chips.len(), 2, "an identical selection is not added twice");
chips.remove(0);
push_chip(&mut chips, chip("Card", &["a:1"]));
assert_eq!(selection_lines(&chips, &[]), ["[Selection] Hero \u{b7} 2 layers (a:2, a:3)", "[Selection] Card (a:1)"]);
let prompt = format!("{}\n\nMake it blue", selection_lines(&chips, &[]).join("\n"));
assert_eq!(chat_msgs::split_context(&prompt).0.len(), 2);
}
#[test]
fn selection_notes_list_the_annotations_of_selected_layers() {
use crate::geom::Rect;
use crate::model::{annotation::Annotation, Node, NodeId, NodeKind};
let mut ed = Editor::default();
let page = ed.active_page;
let id = ed.add_node(Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0)), None);
let mut a = Annotation::new(Some(id), "Use 8pt grid", "rule");
a.id = 1;
a.body = "first line\nsecond".into();
ed.doc.annotations.push(a);
let chips = [SelChip { label: "x".into(), keys: vec![crate::mcp::exec::node_key(&ed, page, id)] }];
let notes = selection_notes(&ed, &chips);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("[rule]") && notes[0].contains("Use 8pt grid \u{2014} first line") && !notes[0].contains("second"), "{notes:?}");
let lines = selection_lines(&chips, &notes);
assert_eq!(chat_msgs::split_context(&format!("{}\n\nhi", lines.join("\n"))).0.len(), 2);
assert_eq!(selection_notes(&ed, &[]), Vec::<String>::new());
}
#[cfg(unix)]
fn fake_claude(dir: &std::path::Path) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir_all(dir).unwrap();
let bin = dir.join("claude");
std::fs::write(
&bin,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 9.9.9; exit 0; fi\ncat >/dev/null\n\
echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"fake-ran\"}}}'\n\
echo '{\"type\":\"result\",\"session_id\":\"s\"}'\n",
)
.unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
/// Root cause of "Send does nothing": detection used the path set in Settings, `send` did not,
/// so with the Dock's bare PATH the turn failed with "not found" although the row said Ready.
#[test]
fn cli_paths_come_from_the_agents_map_first() {
use super::super::settings::{AgentPrefs, ChatPrefs};
let mut prefs = ChatPrefs { codex_path: Some("/old/codex".into()), ..Default::default() };
assert_eq!(cli_binary(&prefs, BackendKind::Codex), Some(PathBuf::from("/old/codex")));
prefs.agents.insert("codex".into(), AgentPrefs { path: Some("/new/codex".into()), ..Default::default() });
assert_eq!(cli_binary(&prefs, BackendKind::Codex), Some(PathBuf::from("/new/codex")));
assert_eq!(cli_binary(&prefs, BackendKind::ClaudeCode), None);
}
#[cfg(unix)]
#[test]
fn send_runs_the_claude_path_set_in_settings() {
let dir = std::env::temp_dir().join(format!("papercraft-chat-bin-{:016x}", fastrand::u64(..)));
let bin = fake_claude(&dir.join("bin"));
let prefs = super::super::settings::ChatPrefs { claude_path: Some(bin.display().to_string()), ..Default::default() };
assert_eq!(cli_binary(&prefs, BackendKind::ClaudeCode), Some(bin));
assert_eq!(cli_binary(&prefs, BackendKind::Codex), None);
let mut st = ChatState { project: Some(dir.clone()), prefs, mcp_url: "http://127.0.0.1:1/mcp".into(), ..Default::default() };
st.draft = "hello".into();
let ctx = egui::Context::default();
st.submit(&ctx).expect("starts with the configured binary");
assert!(st.draft.is_empty() && st.error.is_none());
let t = std::time::Instant::now();
while st.is_running() && t.elapsed() < Duration::from_secs(10) {
st.pump(&ctx);
std::thread::sleep(Duration::from_millis(20));
}
st.pump(&ctx);
let chat = st.chat.as_ref().unwrap();
assert!(chat.messages.iter().any(|m| m.text == "fake-ran"), "{:?}", chat.messages);
assert_eq!(store::list(&dir).len(), 1, "the chat is saved");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_failed_start_keeps_the_text_and_shows_an_error() {
let dir = std::env::temp_dir().join(format!("papercraft-chat-fail-{:016x}", fastrand::u64(..)));
let prefs = super::super::settings::ChatPrefs { claude_path: Some("/nonexistent/claude".into()), ..Default::default() };
let mut st = ChatState { project: Some(dir.clone()), prefs, ..Default::default() };
st.draft = "draw a card".into();
st.sels = vec![chip("Card", &["a:1"])];
let ctx = egui::Context::default();
assert!(st.submit(&ctx).is_err());
assert_eq!(st.draft, "draw a card", "the typed text stays in the composer");
assert_eq!(st.sels.len(), 1);
assert!(st.error.is_some() && st.start_failed, "an error row, never a silent no-op");
assert!(st.chat.as_ref().unwrap().messages.is_empty(), "no message that never went anywhere");
assert!(!st.is_running());
std::fs::remove_dir_all(dir).ok();
}
}
+807
View File
@@ -0,0 +1,807 @@
//! The agent panel (opened from the agent chip), the setup card and the sign-in state of the chat
//! column. `group_agents`, `backend_for`, `current_pick` and `auth_error` are plain functions so
//! they are tested without a frame; the drawing only calls them.
use super::{Detect, EndpointState};
use crate::chat::agents::{self, AgentKind, AgentState};
use crate::chat::{Backend, BackendKind};
use crate::theme::{self, Icon};
use crate::ui::settings::ChatPrefs;
use crate::ui::{chat_msgs, kit};
use egui::{Align, Color32, Id, Layout, Rect, RichText, Sense, Ui, UiBuilder, pos2, vec2};
const ROW_H: f32 = 46.0;
const BADGE: f32 = 28.0;
const PAD: f32 = 8.0;
const PANEL_PAD: f32 = 16.0;
const SEARCH_H: f32 = 34.0;
const FOOTER_H: f32 = 30.0;
const TITLE_H: f32 = 30.0;
const BACKDROP_ALPHA: u8 = 110;
const SHADOW_BLUR: u8 = 40;
const SHADOW_ALPHA: u8 = 150;
/// Room right of a row's text for its chip or button.
/// Agents listed in the setup card; "Show all agents" opens the panel for the rest.
const SETUP_ROWS: usize = 4;
const RIGHT_W: f32 = 104.0;
/// What a row stands for.
#[derive(Debug, Clone, PartialEq)]
pub enum Pick {
/// A registry or custom agent id (`claude-code` and `codex` keep their direct backends).
Agent(String),
Endpoint(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Ready,
NeedsEnable,
NotFound,
WrongProgram(String),
NoKey,
Incomplete,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
pub pick: Pick,
pub name: String,
pub version: String,
pub sub: String,
pub status: Status,
pub docs: Option<&'static str>,
}
impl Row {
pub fn is_selectable(&self) -> bool {
self.status == Status::Ready
}
}
#[derive(Debug, Default, PartialEq)]
pub struct Groups {
/// Ready agents, the direct ones included.
pub detected: Vec<Row>,
/// `npx` agents that are off until the user enables them.
pub npx: Vec<Row>,
/// Not found, or a different program with the agent's name.
pub missing: Vec<Row>,
pub endpoints: Vec<Row>,
pub custom: Vec<Row>,
}
impl Groups {
pub fn is_empty(&self) -> bool {
self.detected.is_empty() && self.npx.is_empty() && self.missing.is_empty() && self.endpoints.is_empty() && self.custom.is_empty()
}
}
/// "gemini 0.44.1" -> "v0.44.1"; "" when no token looks like a version.
fn short_version(v: &str) -> String {
let starts_digit = |t: &str| t.trim_start_matches('v').chars().next().is_some_and(|c| c.is_ascii_digit());
v.split_whitespace().find(|t| starts_digit(t)).map(|t| format!("v{}", t.trim_start_matches('v'))).unwrap_or_default()
}
/// `/Users/me/.local/bin/x` -> `~/.local/bin/x`.
fn tidy(path: &str) -> String {
#[cfg(not(target_arch = "wasm32"))]
if let Some(rest) = dirs::home_dir().and_then(|h| path.strip_prefix(h.to_string_lossy().as_ref()).map(str::to_owned)) {
return format!("~{rest}");
}
path.to_owned()
}
fn matches_query(q: &str, parts: &[&str]) -> bool {
q.is_empty() || parts.iter().any(|p| p.to_lowercase().contains(q))
}
/// The panel's sections for `query` (case-insensitive, on name and id).
pub fn group_agents(d: &Detect, prefs: &ChatPrefs, query: &str) -> Groups {
let q = query.trim().to_lowercase();
let mut g = Groups::default();
for def in agents::AGENTS {
let hidden = prefs.agents.get(def.id).is_some_and(|a| a.hidden);
if hidden || !matches_query(&q, &[def.name, def.id]) {
continue;
}
let mut row = Row { pick: Pick::Agent(def.id.into()), name: def.name.into(), version: String::new(), sub: String::new(), status: Status::NotFound, docs: Some(def.docs_url) };
match d.agent_state(def.id) {
AgentState::Ready { path, version } => {
let npx = matches!(def.kind, AgentKind::Npx { .. });
(row.status, row.version, row.sub) = (Status::Ready, short_version(&version), if npx { "Runs via npx".into() } else { tidy(&path) });
g.detected.push(row);
}
AgentState::NeedsEnable => {
(row.status, row.sub) = (Status::NeedsEnable, "Needs enable \u{b7} runs via npx".into());
g.npx.push(row);
}
AgentState::NotFound => {
row.sub = "Not found".into();
g.missing.push(row);
}
AgentState::WrongProgram { path } => {
(row.sub, row.status) = (format!("Different program at {}", tidy(&path)), Status::WrongProgram(path));
g.missing.push(row);
}
}
}
for ep in &prefs.endpoints {
if !matches_query(&q, &[&ep.name, &ep.id]) {
continue;
}
let state = d.endpoints.iter().find(|s: &&EndpointState| s.id == ep.id);
let host = crate::chat::endpoint::host(&ep.base_url).unwrap_or_else(|| ep.base_url.clone());
let (status, sub) = match state {
_ if !ep.is_complete() => (Status::Incomplete, "Set the URL and model in Settings, Chat".to_owned()),
Some(s) if s.ready && s.key => (Status::Ready, format!("{host} \u{b7} {} \u{b7} key set", ep.model)),
Some(s) if s.ready => (Status::Ready, format!("{host} \u{b7} {}", ep.model)),
_ => (Status::NoKey, "No key. Add one in Settings, Chat".to_owned()),
};
g.endpoints.push(Row { pick: Pick::Endpoint(ep.id.clone()), name: ep.name.clone(), version: String::new(), sub, status, docs: None });
}
for c in &prefs.custom_agents {
if !matches_query(&q, &[&c.name, &c.id]) {
continue;
}
let (status, sub) = match d.agent_state(&c.id) {
AgentState::Ready { .. } => (Status::Ready, format!("{} {}", c.command, c.args).trim().to_owned()),
_ => (Status::NotFound, format!("Not found: {}", c.command)),
};
g.custom.push(Row { pick: Pick::Agent(c.id.clone()), name: c.name.clone(), version: String::new(), sub, status, docs: None });
}
g
}
/// What arrows and Enter walk through, in the order the panel draws it.
pub fn selectable(g: &Groups) -> Vec<Pick> {
[&g.detected, &g.endpoints, &g.custom].into_iter().flatten().filter(|r| r.is_selectable()).map(|r| r.pick.clone()).collect()
}
/// The backend a chat gets when this row is chosen.
pub fn backend_for(pick: &Pick, prefs: &ChatPrefs) -> Backend {
match pick {
Pick::Agent(id) => match id.as_str() {
"claude-code" => Backend { kind: BackendKind::ClaudeCode, ..Default::default() },
"codex" => Backend { kind: BackendKind::Codex, ..Default::default() },
_ => Backend { kind: BackendKind::Acp, agent: Some(id.clone()), ..Default::default() },
},
Pick::Endpoint(id) => {
let ep = prefs.endpoint(Some(id));
Backend {
kind: BackendKind::Anthropic,
model: Some(ep.model.clone()),
effort: ep.is_claude_host().then(|| prefs.api_effort.clone()),
endpoint: Some(ep.id.clone()),
cli_session: None,
agent: None,
}
}
}
}
/// The row a chat's backend belongs to (the checkmark).
pub fn current_pick(b: &Backend, prefs: &ChatPrefs) -> Pick {
match b.kind {
BackendKind::ClaudeCode => Pick::Agent("claude-code".into()),
BackendKind::Codex => Pick::Agent("codex".into()),
BackendKind::Acp => Pick::Agent(b.agent.clone().unwrap_or_default()),
BackendKind::Anthropic => Pick::Endpoint(b.endpoint.clone().unwrap_or_else(|| prefs.default_endpoint.clone())),
}
}
/// Another agent or endpoint is another conversation: the old session id does not carry over.
pub fn same_target(a: &Backend, b: &Backend) -> bool {
(a.kind, &a.agent, &a.endpoint) == (b.kind, &b.agent, &b.endpoint)
}
// ---- sign-in state ----
/// A turn failed because the agent is signed out.
#[derive(Debug, Clone, PartialEq)]
pub struct SignIn {
pub title: String,
pub hint: String,
}
/// The agent's own error says it needs a login (ACP error code -32000 reads "Authentication
/// required"; a few agents word it differently).
pub fn auth_error(e: &str) -> bool {
const PHRASES: [&str; 8] =
["authentication required", "auth_required", "not authenticated", "not logged in", "login required", "please log in", "please sign in", "unauthenticated"];
let e = e.to_lowercase();
PHRASES.iter().any(|p| e.contains(p))
}
/// The command from each agent's own docs that signs it in. None: the card tells to use its own app.
fn login_command(id: &str) -> Option<&'static str> {
Some(match id {
"cursor" => "cursor-agent login",
"opencode" => "opencode auth login",
"codex" | "codex-acp" => "codex login",
"gemini" => "gemini",
"goose" => "goose configure",
"claude-acp" => "claude /login",
_ => return None,
})
}
pub fn signin_for(id: &str, name: &str, error: &str) -> Option<SignIn> {
if !auth_error(error) {
return None;
}
let hint = login_command(id).map_or_else(|| "Sign in with the agent's own app, then retry.".to_owned(), |c| format!("Run `{c}` in a terminal, then retry."));
Some(SignIn { title: format!("{name} needs you to sign in"), hint })
}
// ---- drawing ----
pub enum Act {
Select(Pick),
/// Model or effort of an Anthropic endpoint changed.
SetBackend(Backend),
Enable(String),
/// Settings, Chat: set a path, add an endpoint or a custom agent.
Settings,
DetectAgain,
OpenPanel,
Close,
}
#[derive(Default)]
pub struct PanelState {
pub query: String,
/// Row the arrow keys are on (index into [`selectable`]).
pub sel: usize,
/// Focus the search field on the next frame.
pub fresh: bool,
pub missing_open: bool,
}
pub struct PanelIn<'a> {
pub detect: &'a Detect,
pub prefs: &'a ChatPrefs,
pub backend: &'a Backend,
pub running: bool,
/// The chat column, the agent chip, and the top of the composer.
pub column: Rect,
pub chip: Rect,
pub composer_top: f32,
}
enum Badge<'a> {
Letter(&'a str),
Icon(Icon),
}
struct Spec<'a> {
id: Id,
badge: Badge<'a>,
name: &'a str,
version: &'a str,
sub: &'a str,
ink: Color32,
fill: Option<Color32>,
click: bool,
}
/// One row's frame: hover, badge, name, version and sub line. Returns the row rect and its click.
fn shell(ui: &mut Ui, s: &Spec) -> (Rect, bool) {
let p = theme::pal(ui);
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover());
let resp = ui.interact(rect, s.id, Sense::click());
let hover = s.click && resp.hovered();
if let Some(f) = s.fill.or(hover.then_some(p.hover)) {
ui.painter().rect_filled(rect, theme::RADIUS_LG, f);
}
if hover {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let badge = Rect::from_min_size(pos2(rect.left() + PAD, rect.center().y - BADGE / 2.0), vec2(BADGE, BADGE));
ui.painter().rect_filled(badge, theme::RADIUS_LG, p.field);
match s.badge {
Badge::Letter(l) => {
ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, l, theme::semibold(theme::BODY), s.ink);
}
Badge::Icon(i) => theme::paint_icon(ui.painter(), badge.shrink(7.0), i, s.ink),
}
let x = badge.right() + PAD + 2.0;
let text = ui.painter().with_clip_rect(Rect::from_min_max(rect.min, pos2(rect.right() - RIGHT_W, rect.bottom())));
let g = text.layout_no_wrap(s.name.to_owned(), theme::semibold(theme::BODY), s.ink);
let w = g.size().x;
text.galley(pos2(x, rect.top() + 7.0), g, s.ink);
if !s.version.is_empty() {
text.text(pos2(x + w + 6.0, rect.top() + 9.0), egui::Align2::LEFT_TOP, s.version, egui::FontId::proportional(theme::SECONDARY), p.tertiary);
}
if !s.sub.is_empty() {
text.text(pos2(x, rect.top() + 25.0), egui::Align2::LEFT_TOP, s.sub, egui::FontId::proportional(theme::SECONDARY), p.secondary);
}
(rect, resp.clicked())
}
/// Widgets laid right to left inside a row.
fn right<R>(ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Ui) -> R) -> R {
let area = Rect::from_min_max(pos2(rect.left(), rect.top()), pos2(rect.right() - PAD, rect.bottom()));
ui.scope_builder(UiBuilder::new().max_rect(area).layout(Layout::right_to_left(Align::Center)), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
f(ui)
})
.inner
}
fn check(ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let c = theme::accent();
let pt = |x: f32, y: f32| pos2(r.left() + x, r.top() + y);
ui.painter().add(egui::Shape::line(vec![pt(3.0, 8.5), pt(6.5, 12.0), pt(13.0, 4.5)], egui::Stroke::new(1.6, c)));
}
fn agent_row(ui: &mut Ui, scope: &str, r: &Row, selected: bool, focused: bool) -> Option<Act> {
let p = theme::pal(ui);
let ready = r.is_selectable();
let missing = matches!(r.status, Status::NotFound | Status::WrongProgram(_));
let ink = if missing { p.tertiary } else { p.text };
let id = Id::new(("chat-agent-row", scope, format!("{:?}", r.pick)));
let letter = r.name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
let spec = Spec {
id,
badge: Badge::Letter(&letter),
name: &r.name,
version: &r.version,
// A missing agent's sub line is drawn below, with its links.
sub: if missing { "" } else { &r.sub },
ink,
fill: (selected || focused).then_some(p.hover),
click: ready,
};
let (rect, clicked) = shell(ui, &spec);
let mut act = None;
let mut chip_click = false;
if missing {
let line = Rect::from_min_max(pos2(rect.left() + PAD + BADGE + PAD + 2.0, rect.top() + 23.0), pos2(rect.right() - RIGHT_W, rect.bottom()));
ui.scope_builder(UiBuilder::new().max_rect(line).layout(Layout::left_to_right(Align::Center)), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if ui.link(RichText::new("Set path\u{2026}").size(theme::SECONDARY)).clicked() {
act = Some(Act::Settings);
}
if let Some(url) = r.docs {
ui.hyperlink_to(RichText::new("Docs").size(theme::SECONDARY), url);
}
if matches!(r.status, Status::WrongProgram(_)) {
ui.add(egui::Label::new(RichText::new(&r.sub).size(theme::SECONDARY).color(p.tertiary)).truncate());
}
});
}
right(ui, rect, |ui| {
if selected {
check(ui);
}
match &r.status {
Status::Ready => chip_click = kit::chip_ink(ui, "Ready", Some(p.success)).clicked(),
Status::NeedsEnable => {
if kit::chip_ink(ui, "Enable", Some(theme::accent())).clicked() {
act = Some(Act::Enable(match &r.pick {
Pick::Agent(id) => id.clone(),
Pick::Endpoint(id) => id.clone(),
}));
}
}
Status::NotFound => drop(kit::chip_ink(ui, "Not found", None)),
Status::WrongProgram(_) => drop(kit::chip_ink(ui, "Wrong program", Some(p.warning))),
Status::NoKey => drop(kit::chip_ink(ui, "No key", Some(p.warning))),
Status::Incomplete => drop(kit::chip_ink(ui, "Set up", Some(p.warning))),
}
});
if (clicked || chip_click) && ready {
act = Some(Act::Select(r.pick.clone()));
}
act
}
/// A card-like row that leads to Settings: "Add an API endpoint", "Add custom agent".
fn nav_row(ui: &mut Ui, scope: &str, icon: Icon, name: &str, sub: &str, card: bool) -> bool {
let p = theme::pal(ui);
let spec = Spec { id: Id::new(("chat-nav-row", scope, name)), badge: Badge::Icon(icon), name, version: "", sub, ink: p.text, fill: card.then_some(p.popover), click: true };
let (rect, clicked) = shell(ui, &spec);
if card {
let c = rect.right_center() - vec2(PAD + 6.0, 0.0);
theme::paint_icon(ui.painter(), Rect::from_center_size(c, vec2(14.0, 14.0)), Icon::ChevronRight, p.secondary);
}
clicked
}
enum Fold {
None,
Action(&'static str),
Open(bool),
}
/// "DETECTED 7" with an action ("Add") or a fold chevron on the right. True when clicked.
fn section(ui: &mut Ui, title: &str, count: Option<usize>, fold: Fold) -> bool {
let p = theme::pal(ui);
ui.add_space(6.0);
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
let resp = ui.interact(rect, Id::new(("chat-agent-section", title)), if matches!(fold, Fold::None) { Sense::hover() } else { Sense::click() });
let font = theme::semibold(theme::SECONDARY);
let x = rect.left() + PAD;
let g = ui.painter().layout_no_wrap(title.to_uppercase(), font.clone(), p.tertiary);
let w = g.size().x;
ui.painter().galley(pos2(x, rect.center().y - g.size().y / 2.0), g, p.tertiary);
let edge = rect.right() - PAD;
match fold {
Fold::None => {
if let Some(n) = count {
ui.painter().text(pos2(edge, rect.center().y), egui::Align2::RIGHT_CENTER, n.to_string(), font, p.tertiary);
}
}
Fold::Action(t) => {
ui.painter().text(pos2(edge, rect.center().y), egui::Align2::RIGHT_CENTER, t, font, theme::accent());
}
Fold::Open(open) => {
let icon = if open { Icon::ChevronDown } else { Icon::ChevronRight };
theme::paint_icon(ui.painter(), Rect::from_center_size(pos2(edge - 6.0, rect.center().y), vec2(12.0, 12.0)), icon, p.tertiary);
if let Some(n) = count {
ui.painter().text(pos2(x + w + 8.0, rect.center().y), egui::Align2::LEFT_CENTER, n.to_string(), font, p.tertiary);
}
}
}
resp.clicked()
}
/// The panel over the chat column. Returns what the user did this frame.
pub fn panel(ctx: &egui::Context, ps: &mut PanelState, i: &PanelIn) -> Option<Act> {
let p = theme::pal_from_ctx(ctx);
let g = group_agents(i.detect, i.prefs, &ps.query);
let flat = selectable(&g);
let (down, up, enter, esc) = ctx.input_mut(|inp| {
use egui::{Key, Modifiers as M};
(inp.consume_key(M::NONE, Key::ArrowDown), inp.consume_key(M::NONE, Key::ArrowUp), inp.consume_key(M::NONE, Key::Enter), inp.consume_key(M::NONE, Key::Escape))
});
if esc {
return Some(Act::Close);
}
if down {
ps.sel += 1;
}
if up {
ps.sel = ps.sel.saturating_sub(1);
}
ps.sel = ps.sel.min(flat.len().saturating_sub(1));
let mut act = None;
if enter && !i.running {
act = flat.get(ps.sel).cloned().map(Act::Select);
}
// Dimmed backdrop over the column: a click outside the panel closes it.
egui::Area::new("chat-agent-backdrop".into()).order(egui::Order::Foreground).fade_in(false).fixed_pos(i.column.min).show(ctx, |ui| {
let (r, resp) = ui.allocate_exact_size(i.column.size(), Sense::click());
ui.painter().rect_filled(r, theme::RADIUS_PANEL, Color32::from_black_alpha(BACKDROP_ALPHA));
if resp.clicked() {
act = Some(Act::Close);
}
});
let top = i.chip.bottom() + 6.0;
let height = (i.composer_top - 10.0 - top).max(240.0);
let list_h = height - 2.0 * PANEL_PAD - TITLE_H - SEARCH_H - FOOTER_H - 24.0;
egui::Area::new("chat-agent-panel".into()).order(egui::Order::Foreground).fade_in(false).fixed_pos(pos2(i.column.left(), top)).show(ctx, |ui| {
egui::Frame::new()
.fill(p.popover)
.stroke(egui::Stroke::new(1.0, p.separator))
.corner_radius(theme::RADIUS_PANEL)
.inner_margin(PANEL_PAD)
.shadow(egui::Shadow { offset: [0, 12], blur: SHADOW_BLUR, spread: 0, color: Color32::from_black_alpha(SHADOW_ALPHA) })
.show(ui, |ui| {
ui.set_width(i.column.width() - 2.0 * PANEL_PAD);
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(RichText::new("Choose an agent").font(theme::semibold(15.0)));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if kit::close_button(ui).clicked() {
act = Some(Act::Close);
}
});
});
ui.add_space(4.0);
let search = ui.add(egui::TextEdit::singleline(&mut ps.query).hint_text("Search agents").margin(theme::INPUT_MARGIN).desired_width(f32::INFINITY));
if std::mem::take(&mut ps.fresh) {
search.request_focus();
}
if search.changed() {
ps.sel = 0;
}
let selected = current_pick(i.backend, i.prefs);
let mut n = 0usize;
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if !g.detected.is_empty() {
section(ui, "Detected", Some(g.detected.len()), Fold::None);
draw_rows(ui, &g.detected, &selected, ps.sel, &mut n, i.running, &mut act);
}
if !g.npx.is_empty() {
section(ui, "Enable via npx", Some(g.npx.len()), Fold::None);
draw_rows(ui, &g.npx, &selected, ps.sel, &mut n, i.running, &mut act);
ui.label(RichText::new("Runs code fetched by npx").size(theme::SECONDARY).color(p.tertiary));
}
if !g.missing.is_empty() && section(ui, "Not found", Some(g.missing.len()), Fold::Open(ps.missing_open)) {
ps.missing_open = !ps.missing_open;
}
if !g.missing.is_empty() && ps.missing_open {
draw_rows(ui, &g.missing, &selected, ps.sel, &mut n, i.running, &mut act);
}
if !g.endpoints.is_empty() && section(ui, "API endpoints", None, Fold::Action("Add")) {
act = Some(Act::Settings);
}
draw_rows(ui, &g.endpoints, &selected, ps.sel, &mut n, i.running, &mut act);
section(ui, "Custom", None, Fold::None);
draw_rows(ui, &g.custom, &selected, ps.sel, &mut n, i.running, &mut act);
if nav_row(ui, "panel", Icon::Plus, "Add custom agent", "Command and arguments (ACP)", false) {
act = Some(Act::Settings);
}
if g.is_empty() {
ui.label(RichText::new("No agent matches the search").size(theme::SECONDARY).color(p.tertiary));
}
if let Some(a) = model_block(ui, i) {
act = Some(a);
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if kit::chip(ui, "Detect again", false).clicked() {
act = Some(Act::DetectAgain);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if kit::chip_ink(ui, "Settings \u{2192} Chat", Some(theme::accent())).clicked() {
act = Some(Act::Settings);
}
});
});
});
});
act
}
fn draw_rows(ui: &mut Ui, rows: &[Row], selected: &Pick, sel: usize, n: &mut usize, running: bool, act: &mut Option<Act>) {
for r in rows {
let focused = r.is_selectable() && *n == sel;
*n += usize::from(r.is_selectable());
let a = ui.add_enabled_ui(!(running && r.is_selectable()), |ui| agent_row(ui, "panel", r, r.pick == *selected, focused)).inner;
*act = a.or(act.take());
}
}
/// Model and effort of the selected endpoint when it is a Claude host (the old picker's blocks).
fn model_block(ui: &mut Ui, i: &PanelIn) -> Option<Act> {
let b = i.backend;
let ep = i.prefs.endpoint(b.endpoint.as_deref());
if b.kind != BackendKind::Anthropic || !ep.is_claude_host() {
return None;
}
let mut out = None;
let model = b.model.clone().unwrap_or_else(|| ep.model.clone());
section(ui, "Model", None, Fold::None);
for (id, label) in crate::ui::settings::API_MODELS {
let r = ui.add(egui::Button::selectable(model == id, RichText::new(format!("{label} {id}")).size(theme::SECONDARY + 1.0)).frame(false));
if r.clicked() && !i.running {
out = Some(Act::SetBackend(Backend { model: Some(id.into()), ..b.clone() }));
}
}
section(ui, "Effort", None, Fold::None);
let effort = b.effort.clone().unwrap_or_else(|| i.prefs.api_effort.clone());
let items: Vec<(&str, Option<&str>)> = crate::ui::settings::API_EFFORTS.iter().map(|(_, l)| (*l, None)).collect();
let cur = crate::ui::settings::API_EFFORTS.iter().position(|(id, _)| *id == effort).unwrap_or(1);
if let Some(n) = kit::segmented(ui, &items, cur)
&& !i.running
{
out = Some(Act::SetBackend(Backend { effort: Some(crate::ui::settings::API_EFFORTS[n].0.into()), ..b.clone() }));
}
out
}
/// "Connect an agent": detected agents to pick, plus API endpoint and custom agent.
pub fn setup(ui: &mut Ui, d: &Detect, prefs: &ChatPrefs) -> Option<Act> {
let p = theme::pal(ui);
let g = group_agents(d, prefs, "");
let ready: Vec<&Row> = g.detected.iter().chain(g.custom.iter().filter(|r| r.is_selectable())).collect();
let mut act = None;
ui.add_space(ui.available_height() * 0.04);
egui::Frame::new().fill(p.popover).corner_radius(theme::RADIUS_PANEL).inner_margin(PANEL_PAD).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 4.0;
kit::badge(ui, Icon::Laser, theme::accent(), 40.0);
ui.add_space(4.0);
ui.label(RichText::new("Connect an agent").font(theme::semibold(18.0)));
let blurb = match ready.len() {
0 => "PaperCraft did not find an agent on this computer. Add an API endpoint or a custom agent.".to_owned(),
1 => "PaperCraft found 1 agent on this computer. Pick it to start designing by conversation.".to_owned(),
n => format!("PaperCraft found {n} agents on this computer. Pick one to start designing by conversation."),
};
ui.add(egui::Label::new(RichText::new(blurb).color(p.secondary)));
if !ready.is_empty() {
section(ui, "Detected on this computer", Some(ready.len()), Fold::None);
for r in ready.into_iter().take(SETUP_ROWS) {
act = agent_row(ui, "setup", r, false, false).or(act.take());
}
}
if ui.link(RichText::new("Show all agents").size(theme::SECONDARY)).clicked() {
act = Some(Act::OpenPanel);
}
});
ui.add_space(6.0);
if nav_row(ui, "setup", Icon::Plus, "Add an API endpoint", "Anthropic, OpenAI-compatible or MiniMax-style", true) {
act = Some(Act::Settings);
}
if nav_row(ui, "setup", Icon::Plus, "Add custom agent", "Any ACP agent: name, command, arguments", true) {
act = Some(Act::Settings);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if kit::chip(ui, "Detect again", false).clicked() {
act = Some(Act::DetectAgain);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if kit::chip_ink(ui, "Settings \u{2192} Chat", Some(theme::accent())).clicked() {
act = Some(Act::Settings);
}
});
});
act
}
/// The sign-in card: what to run, and Retry (through `acts`).
pub fn signin_card(ui: &mut Ui, s: &SignIn, acts: &mut chat_msgs::Acts) {
chat_msgs::notice_card(ui, &s.title, &s.hint, acts);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::agents::AgentStatus;
use crate::ui::settings::CustomAgent;
fn ready(id: &str, v: &str) -> AgentStatus {
AgentStatus { id: id.into(), state: AgentState::Ready { path: format!("/bin/{id}"), version: v.into() } }
}
fn detect() -> Detect {
Detect {
claude: Some(("/bin/claude".into(), "2.1.0 (Claude Code)".into())),
agents: vec![
ready("gemini", "gemini 0.44.1"),
AgentStatus { id: "pi".into(), state: AgentState::NeedsEnable },
AgentStatus { id: "goose".into(), state: AgentState::WrongProgram { path: "/opt/goose".into() } },
ready("mine", ""),
],
endpoints: vec![EndpointState { id: "anthropic".into(), key: true, ready: true }],
default_endpoint: "anthropic".into(),
..Default::default()
}
}
fn prefs() -> ChatPrefs {
let mut p = ChatPrefs::default();
p.custom_agents.push(CustomAgent { id: "mine".into(), name: "Mine".into(), command: "my-agent".into(), args: "--stdio".into() });
p
}
fn names(rows: &[Row]) -> Vec<&str> {
rows.iter().map(|r| r.name.as_str()).collect()
}
#[test]
fn groups_split_ready_npx_missing_endpoints_and_custom() {
let g = group_agents(&detect(), &prefs(), "");
assert_eq!(names(&g.detected), ["Claude Code", "Gemini CLI"]);
assert_eq!(g.detected[1].version, "v0.44.1");
assert!(g.npx.iter().any(|r| r.name == "Pi" && r.status == Status::NeedsEnable));
assert!(g.missing.iter().any(|r| r.name == "Codex" && r.status == Status::NotFound && r.docs.is_some()));
let goose = g.missing.iter().find(|r| r.name == "Goose").unwrap();
assert_eq!(goose.sub, "Different program at /opt/goose");
assert_eq!(names(&g.endpoints), ["Anthropic"]);
assert_eq!((g.custom[0].name.as_str(), g.custom[0].status.clone()), ("Mine", Status::Ready));
assert!(!g.detected.iter().any(|r| r.name == "Mine"), "custom agents stay in their own section");
}
#[test]
fn hidden_agents_stay_out_of_the_panel() {
let mut p = prefs();
p.agents.insert("gemini".into(), crate::ui::settings::AgentPrefs { hidden: true, ..Default::default() });
let g = group_agents(&detect(), &p, "");
assert_eq!(names(&g.detected), ["Claude Code"]);
}
#[test]
fn query_filters_every_section_case_insensitively() {
let g = group_agents(&detect(), &prefs(), " GEM ");
assert_eq!(names(&g.detected), ["Gemini CLI"]);
assert!(g.npx.is_empty() && g.missing.is_empty() && g.endpoints.is_empty() && g.custom.is_empty());
assert!(group_agents(&detect(), &prefs(), "zzz").is_empty());
assert_eq!(names(&group_agents(&detect(), &prefs(), "mine").custom), ["Mine"]);
}
#[test]
fn only_ready_rows_are_selectable_in_draw_order() {
let picks = selectable(&group_agents(&detect(), &prefs(), ""));
assert_eq!(picks, [Pick::Agent("claude-code".into()), Pick::Agent("gemini".into()), Pick::Endpoint("anthropic".into()), Pick::Agent("mine".into())]);
}
#[test]
fn a_row_maps_to_its_backend_and_back() {
let prefs = prefs();
let claude = backend_for(&Pick::Agent("claude-code".into()), &prefs);
assert_eq!(claude.kind, BackendKind::ClaudeCode);
assert_eq!(backend_for(&Pick::Agent("codex".into()), &prefs).kind, BackendKind::Codex);
let acp = backend_for(&Pick::Agent("gemini".into()), &prefs);
assert_eq!((acp.kind, acp.agent.as_deref()), (BackendKind::Acp, Some("gemini")));
let ep = backend_for(&Pick::Endpoint("anthropic".into()), &prefs);
assert_eq!((ep.kind, ep.endpoint.as_deref()), (BackendKind::Anthropic, Some("anthropic")));
assert!(ep.model.is_some());
for pick in [Pick::Agent("claude-code".into()), Pick::Agent("gemini".into()), Pick::Endpoint("anthropic".into())] {
assert_eq!(current_pick(&backend_for(&pick, &prefs), &prefs), pick);
}
// No endpoint stored means the default one.
assert_eq!(current_pick(&Backend { kind: BackendKind::Anthropic, ..Default::default() }, &prefs), Pick::Endpoint(prefs.default_endpoint.clone()));
assert!(!same_target(&acp, &claude) && !same_target(&acp, &backend_for(&Pick::Agent("opencode".into()), &prefs)));
assert!(same_target(&acp, &Backend { cli_session: Some("s".into()), ..acp.clone() }));
}
#[test]
fn auth_errors_are_told_apart_from_server_errors() {
assert!(auth_error("/bin/cursor-agent: session/new: Authentication required"));
assert!(auth_error("Not logged in. Please log in"));
assert!(!auth_error("/opt/homebrew/bin/gemini: session/prompt: Internal error (503 service unavailable)"));
assert!(!auth_error("Could not start /x: No such file"));
let s = signin_for("cursor", "Cursor", "x: Authentication required").unwrap();
assert_eq!(s.title, "Cursor needs you to sign in");
assert_eq!(s.hint, "Run `cursor-agent login` in a terminal, then retry.");
assert!(signin_for("kimi", "Kimi", "Authentication required").unwrap().hint.contains("own app"));
assert_eq!(signin_for("cursor", "Cursor", "boom"), None);
}
#[test]
fn version_and_home_are_shortened() {
assert_eq!(short_version("opencode 1.18.30"), "v1.18.30");
assert_eq!(short_version("v2.1"), "v2.1");
assert_eq!(short_version("Cursor Agent"), "");
}
fn run(ctx: &egui::Context, events: Vec<egui::Event>, f: &mut dyn FnMut(&mut Ui)) {
for n in 0..3 {
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 800.0))),
events: if n == 2 { events.clone() } else { vec![] },
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| f(ui));
out.textures_delta.clear();
}
}
fn key(k: egui::Key) -> egui::Event {
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
#[test]
fn panel_and_setup_draw_and_keys_select() {
let ctx = egui::Context::default();
crate::ui::setup_style(&ctx);
let (d, prefs) = (detect(), prefs());
let backend = Backend::default();
let col = Rect::from_min_size(pos2(10.0, 60.0), vec2(450.0, 700.0));
let chip = Rect::from_min_size(pos2(10.0, 90.0), vec2(120.0, 22.0));
let drive = |events: Vec<egui::Event>, ps: &mut PanelState| {
let mut last = None;
run(&ctx, events, &mut |_| last = panel(&ctx, ps, &PanelIn { detect: &d, prefs: &prefs, backend: &backend, running: false, column: col, chip, composer_top: 700.0 }));
last
};
let mut ps = PanelState { fresh: true, ..Default::default() };
assert!(drive(vec![], &mut ps).is_none(), "nothing happens without input");
let two = drive(vec![key(egui::Key::ArrowDown), key(egui::Key::Enter)], &mut ps);
assert!(matches!(two, Some(Act::Select(Pick::Agent(id))) if id == "gemini"), "second ready row");
assert!(matches!(drive(vec![key(egui::Key::Escape)], &mut ps), Some(Act::Close)));
run(&ctx, vec![], &mut |ui| {
assert!(setup(ui, &Detect::default(), &prefs).is_none());
assert!(setup(ui, &d, &prefs).is_none());
});
}
}
+30 -8
View File
@@ -53,6 +53,7 @@ pub struct View<'a> {
pub runs: &'a [Run],
pub approvals: &'a [Approval],
pub error: Option<&'a str>,
pub signin: Option<&'a super::chat::chat_agents::SignIn>,
}
// ---------------------------------------------------------------------------------- pure helpers
@@ -832,14 +833,7 @@ pub fn list(ui: &mut Ui, chat: &Chat, v: &View, acts: &mut Acts) {
});
}
if let Some(e) = v.error {
let p = theme::pal(ui);
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(e).color(p.danger)).wrap());
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Restore), "Retry").clicked() {
acts.retry_last = true;
}
});
error_row(ui, e, v.signin, acts);
}
ui.add_space(4.0);
if jump {
@@ -862,6 +856,34 @@ pub fn list(ui: &mut Ui, chat: &Chat, v: &View, acts: &mut Acts) {
});
}
/// A card with a title, a hint and Retry: the sign-in state of an agent.
pub fn notice_card(ui: &mut Ui, title: &str, hint: &str, acts: &mut Acts) {
let p = theme::pal(ui);
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(title).font(theme::semibold(theme::BODY)).color(p.warning));
ui.add(egui::Label::new(RichText::new(hint).size(theme::SECONDARY).color(p.secondary)).wrap());
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Restore), "Retry").clicked() {
acts.retry_last = true;
}
});
}
/// The error card with Retry; an agent that is signed out gets its sign-in card instead.
pub fn error_row(ui: &mut Ui, e: &str, signin: Option<&super::chat::chat_agents::SignIn>, acts: &mut Acts) {
if let Some(s) = signin {
return super::chat::chat_agents::signin_card(ui, s, acts);
}
let p = theme::pal(ui);
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(e).color(p.danger)).wrap());
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Restore), "Retry").clicked() {
acts.retry_last = true;
}
});
}
/// Suggestions, shown for a chat without messages.
pub fn empty(ui: &mut Ui, acts: &mut Acts) {
let p = theme::pal(ui);
+2
View File
@@ -493,6 +493,8 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
sketch_section(ui, ed, &ids, &first);
}
separator(ui);
super::annotations::inspector_section(ui, ed, st, ids[0]);
separator(ui);
export_section(ui, ed, st);
});
}
+4 -38
View File
@@ -21,12 +21,12 @@ fn rest(ui: &Ui, name: &str, rect: Rect, tint: Color32) {
(animation.first_frame(), animation.last_frame())
};
paint_frame(ui, name, rect, tint, off);
} else if let Some(icon) = static_icon(name) {
} else if let Some(icon) = crate::theme::icon_by_name(name) {
crate::theme::paint_icon(ui.painter(), rect, icon, tint);
}
}
/// The Lucide name of an app icon, when it has one (the inverse of [`static_icon`]).
/// The Lucide name of an app icon, when it has one (the inverse of [`crate::theme::icon_by_name`]).
#[must_use]
pub fn lucide_name(icon: crate::theme::Icon) -> Option<&'static str> {
use crate::theme::Icon;
@@ -52,40 +52,6 @@ pub fn lucide_name(icon: crate::theme::Icon) -> Option<&'static str> {
})
}
/// The app's hand-drawn icon for a Lucide name, when it has one.
#[must_use]
pub fn static_icon(name: &str) -> Option<crate::theme::Icon> {
use crate::theme::Icon;
Some(match name {
"chevron-right" => Icon::ChevronRight,
"chevron-down" => Icon::ChevronDown,
"chevron-up" => Icon::ChevronUp,
"plus" => Icon::Plus,
"minus" => Icon::Minus,
"trash-2" => Icon::Trash,
"lock" => Icon::Lock,
"unlock" => Icon::LockOpen,
"eye" => Icon::Eye,
"eye-off" => Icon::EyeSlash,
"panel-left" => Icon::Sidebar,
"hand" => Icon::Hand,
"mouse-pointer-2" => Icon::Pointer,
"pencil" => Icon::Pencil,
"pen" => Icon::Pen,
"eraser" => Icon::Eraser,
"type" => Icon::Text,
"square" => Icon::Rect,
"circle" => Icon::Ellipse,
"image" => Icon::Image,
"star" => Icon::Star,
"grid-3x3" => Icon::Grid,
"ellipsis" => Icon::More,
"download" => Icon::Export,
"circle-question-mark" => Icon::Question,
_ => return None,
})
}
/// Marker name of a toggle's resting frame.
const OFF: &str = "off";
/// Marker name of a toggle's final frame.
@@ -507,8 +473,8 @@ mod tests {
#[test]
fn animated_icons_without_a_hand_drawn_twin_still_resolve() {
assert!(static_icon("chevron-right").is_some());
assert!(static_icon("sparkles").is_none() && has("sparkles"));
assert!(crate::theme::icon_by_name("chevron-right").is_some());
assert!(crate::theme::icon_by_name("sparkles").is_none() && has("sparkles"));
}
}
+22 -1
View File
@@ -2,6 +2,8 @@
//! projects home screen.
pub mod agent;
pub mod annotations;
pub mod annotations_canvas;
pub mod bind;
pub mod chat;
pub mod chat_md;
@@ -150,6 +152,12 @@ pub struct UiState {
/// Chat mode: the chat column replaces the layers and inspector.
pub chat_mode: bool,
pub chat: chat::ChatState,
/// Annotation view state (mode, hidden categories, collapsed notes); not saved.
pub annotations: annotations_canvas::AnnotationsView,
/// Annotation picked on the canvas, for the side view and inspector.
pub selected_annotation: Option<u64>,
/// Annotations UI state (popover, Customize modal, inline edit), not saved.
pub ann_ui: annotations::AnnotationsUi,
}
/// Where a dragged layer lands relative to a row.
@@ -672,7 +680,12 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
UiEvent::ToggleChat => st.set_chat_mode(!st.chat_mode),
UiEvent::ToggleHelp => st.show_help = !st.show_help,
UiEvent::ToggleGrid => st.show_grid_off = !st.show_grid_off,
UiEvent::ToggleAnnotations => {
let d = ed.active_template(ed.active_page).display;
st.annotations.cycle(d);
}
UiEvent::OpenVariables => st.variables.open = !st.variables.open,
UiEvent::Annotate(id) => annotations::open_popover(ed, &mut st.ann_ui, id),
}
}
if st.run_open.is_some() {
@@ -821,6 +834,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
);
}
toolbar(ui.ctx(), ed, canvas);
annotations::overlays(ui.ctx(), ed, st, canvas);
if st.chat_mode && st.chat.inspector {
chat::inspector_overlay(ui.ctx(), ed, st, canvas);
}
@@ -830,6 +844,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
agent_glow(ui.ctx(), st, canvas);
status(ui.ctx(), ed, canvas);
review_overlay(ui.ctx(), ed, st, canvas);
annotations_canvas::draw(ui.ctx(), ed, &mut st.annotations, &mut st.selected_annotation, canvas);
run_toast(ui.ctx(), st, canvas);
palette::draw(ui.ctx(), ed, st, canvas);
if st.show_help {
@@ -1242,19 +1257,21 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
/// components), history, comments and the agent log. Each tab grows with the width; when there is
/// room it also shows its name.
fn side_tabs(ui: &mut Ui, st: &mut UiState) {
const TABS: [(i32, &str); 6] = [
const TABS: [(i32, &str); 7] = [
(0, "Layers"),
(1, "Assets"),
(2, "Design"),
(3, "History"),
(4, "Notes"),
(5, "Agent"),
(6, "Annotations"),
];
let p = theme::pal(ui);
let current = match st.side_extra {
Some(panels::SideView::History) => 3,
Some(panels::SideView::Comments) => 4,
Some(panels::SideView::Agent) => 5,
Some(panels::SideView::Annotations) => 6,
None if st.show_design => 2,
None => i32::from(st.show_library),
};
@@ -1314,6 +1331,7 @@ fn side_tabs(ui: &mut Ui, st: &mut UiState) {
tint,
hovered,
),
6 => theme::paint_icon(ui.painter(), icon_box, Icon::Note, tint),
_ => lottie_icon::paint_hover(
ui, resp.id, "sparkles", icon_box, tint, hovered,
),
@@ -1342,6 +1360,7 @@ fn side_tabs(ui: &mut Ui, st: &mut UiState) {
3 => Some(panels::SideView::History),
4 => Some(panels::SideView::Comments),
5 => Some(panels::SideView::Agent),
6 => Some(panels::SideView::Annotations),
_ => None,
};
}
@@ -1385,6 +1404,7 @@ fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
panels::SideView::History => panels::history(ui, ed, st),
panels::SideView::Comments => panels::comments(ui, ed, st),
panels::SideView::Agent => panels::agent(ui, ed, st),
panels::SideView::Annotations => annotations::panel(ui, ed, st),
}
return;
}
@@ -1764,6 +1784,7 @@ fn tool_icon(t: Tool) -> Icon {
Tool::Arrow => Icon::Arrow,
Tool::Sticky => Icon::Sticky,
Tool::Laser => Icon::Laser,
Tool::Annotate => Icon::Note,
Tool::Star => Icon::Star,
Tool::Pen => Icon::Pen,
Tool::Line => Icon::Line,
+4
View File
@@ -166,6 +166,10 @@ pub fn entries(ed: &Editor, st: &UiState) -> Vec<Entry> {
"Show agent activity",
Command::Side(super::panels::SideView::Agent),
),
(
"Show annotations",
Command::Side(super::panels::SideView::Annotations),
),
] {
out.push(Entry {
label: label.into(),
+24 -2
View File
@@ -12,6 +12,7 @@ pub enum SideView {
History,
Comments,
Agent,
Annotations,
}
/// Which comments the Comments view shows.
@@ -1036,6 +1037,22 @@ pub(super) fn review(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
variable_row(ui, &n);
}
}
if !d.annotations.is_empty() {
ui.add_space(8.0);
kit::section_header(ui, &format!("Notes · {}", d.annotations.len()), None);
for a in &d.annotations {
let n = crate::mcp::diff::NodeDiff {
id_a: None,
id_b: None,
path: String::new(),
name: a.label.clone(),
kind: "annotation",
status: a.status,
changes: Vec::new(),
};
variable_row(ui, &n);
}
}
}
}
}
@@ -1089,8 +1106,13 @@ fn variable_row(ui: &mut Ui, n: &crate::mcp::diff::NodeDiff) {
let p = theme::pal(ui);
let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 48.0), egui::Sense::hover());
let badge = egui::Rect::from_min_size(rect.left_center() - egui::vec2(0.0, 14.0), egui::vec2(28.0, 28.0));
ui.painter().rect_filled(badge, theme::RADIUS_MD, p.warning.gamma_multiply(0.18));
theme::paint_icon(ui.painter(), badge.shrink(6.0), Icon::Pencil, p.warning);
let (tint, icon) = match n.status {
"added" => (p.success, Icon::Plus),
"removed" => (p.danger, Icon::Minus),
_ => (p.warning, Icon::Pencil),
};
ui.painter().rect_filled(badge, theme::RADIUS_MD, tint.gamma_multiply(0.18));
theme::paint_icon(ui.painter(), badge.shrink(6.0), icon, tint);
let x = rect.left() + 40.0;
let clip = ui.painter().with_clip_rect(rect);
clip.text(egui::pos2(x, rect.center().y - 9.0), egui::Align2::LEFT_CENTER, &n.name, egui::FontId::proportional(theme::BODY), p.text);
+787 -389
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -2,6 +2,9 @@
//! Pure model. `cache_for` derives the per-page `Document` fields the rest of the app reads.
use crate::design_system::sanitize_name;
use crate::model::annotation::{
AnnotationCategory, AnnotationTemplate, DEFAULT_TEMPLATE, builtin_templates, default_categories,
};
use crate::model::Color;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -13,12 +16,26 @@ pub const TOKENS_COLLECTION: &str = "Tokens";
/// Active mode id per collection id (missing = the collection's default mode).
pub type PageModes = BTreeMap<String, String>;
/// Reserved `PageModes` key carrying the page's annotation template override (so it rides the
/// same undo step and save path as the modes). Stored in the page file as `annotationTemplate`.
pub const PAGE_TEMPLATE_KEY: &str = "$annotationTemplate";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DesignSystem {
#[serde(default)]
pub collections: Vec<Collection>,
#[serde(default)]
pub libraries: Vec<EnabledLibrary>,
/// Annotation categories. Empty means the six defaults; written to `design.json` only once
/// edited (an empty list is skipped).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotation_categories: Vec<AnnotationCategory>,
/// Annotation templates. Empty means the built-ins; written once one is edited.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotation_templates: Vec<AnnotationTemplate>,
/// Id of the project's default annotation template (none = Product spec).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotation_template: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -189,6 +206,52 @@ fn under(name: &str, group: &str) -> bool {
// ------------------------------------------------------------------------------ lookup
impl DesignSystem {
pub fn category_by_key(&self, key: &str) -> Option<AnnotationCategory> {
crate::model::annotation::category_by_key(&self.categories(), key).cloned()
}
/// The annotation categories in effect: the stored ones, or the defaults when none.
pub fn categories(&self) -> Vec<AnnotationCategory> {
if self.annotation_categories.is_empty() {
default_categories()
} else {
self.annotation_categories.clone()
}
}
/// The annotation templates in effect: the stored ones, or the built-ins when none.
pub fn templates(&self) -> Vec<AnnotationTemplate> {
if self.annotation_templates.is_empty() {
builtin_templates()
} else {
self.annotation_templates.clone()
}
}
/// A template by id or name (case-insensitive).
pub fn template_by_ref(&self, r: &str) -> Option<AnnotationTemplate> {
let r = r.trim();
let all = self.templates();
let found = all
.iter()
.find(|t| t.id == r)
.or_else(|| all.iter().find(|t| t.name.eq_ignore_ascii_case(r)))
.cloned();
found
}
/// The template a page uses: its own override, else the project default, else Product spec
/// (else the first template when that one was deleted).
pub fn active_template(&self, page_override: Option<&str>) -> AnnotationTemplate {
let all = self.templates();
[page_override, self.annotation_template.as_deref(), Some(DEFAULT_TEMPLATE)]
.into_iter()
.flatten()
.find_map(|id| all.iter().find(|t| t.id == id))
.unwrap_or(&all[0])
.clone()
}
pub fn collection(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id)
}
@@ -687,6 +750,21 @@ pub fn migrate_from_document(doc: &crate::model::Document) -> DesignSystem {
#[cfg(test)]
mod tests {
#[test]
fn annotation_categories_default_and_are_not_written_until_edited() {
let mut d = DesignSystem::default();
assert_eq!(d.categories().len(), 6);
assert_eq!(d.category_by_key("A11Y").unwrap().name, "Accessibility");
assert!(d.category_by_key("nope").is_none());
assert!(!serde_json::to_string(&d).unwrap().contains("annotation_categories"));
let old: DesignSystem = serde_json::from_str(r#"{"collections":[]}"#).unwrap();
assert_eq!(old, d);
d.annotation_categories = default_categories();
d.annotation_categories[0].name = "Rule".into();
let back: DesignSystem = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back.categories()[0].name, "Rule");
}
use super::*;
use crate::model::Document;
+366
View File
@@ -0,0 +1,366 @@
//! Annotations follow duplicated frames through the MCP tools.
use papercraft::mcp::{Out, exec};
use papercraft::model::NodeId;
use papercraft::model::annotation::Annotation;
use papercraft::project::Store;
use serde_json::{Value, json};
fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
let o = exec::call(h, name, &args).unwrap_or_else(|e| panic!("{name} failed: {e}"));
match o.into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
_ => Value::Null,
}
}
fn num(id: &Value) -> NodeId {
NodeId(id.as_str().unwrap().rsplit(':').next().unwrap().parse().unwrap())
}
#[test]
fn duplicate_nodes_copies_annotations_same_page_and_across_pages() {
let mut fixture = Fixture::new();
let h = &mut fixture.host;
h.session.create(&mut h.ed, "Ann").unwrap();
let ab = call(h, "create_artboard", json!({"name": "A",
"styles": {"width": "100px", "height": "40px"}}));
let src = num(&ab["id"]);
h.ed.add_annotation(Annotation::new(Some(src), "rule", "data"));
let d = call(h, "duplicate_nodes", json!({"nodes": [{"id": ab["id"]}]}));
let copy = num(&d["duplicates"][0]["newId"]);
let anns = &h.ed.page_doc(0).annotations;
assert_eq!(anns.len(), 2);
assert_eq!(anns.iter().filter(|a| a.target == Some(copy)).count(), 1);
assert_ne!(anns[0].id, anns[1].id);
let b = call(h, "create_page", json!({"name": "B"}))["pageId"].clone();
let d = call(h, "duplicate_nodes", json!({"nodes": [{"id": ab["id"], "toPageId": b}]}));
let copy = num(&d["duplicates"][0]["newId"]);
let anns = &h.ed.page_doc(1).annotations;
assert_eq!(anns.len(), 1);
assert_eq!(anns[0].target, Some(copy));
}
struct Fixture {
host: exec::SessionHost,
path: std::path::PathBuf,
}
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("papercraft-annotation-tools-{:016x}", fastrand::u64(..)));
let store = Store::open(&path).unwrap();
Self { host: exec::SessionHost::new(store), path }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.path).unwrap();
}
}
fn frame(h: &mut exec::SessionHost, name: &str) -> Value {
call(h, "create_artboard", json!({"name": name,
"styles": {"width": "300px", "height": "300px"}}))["id"].clone()
}
#[test]
fn annotation_batch_by_id_and_name_is_one_undo_step() {
use papercraft::model::annotation::{Author, CARD_H, card_world};
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
let before = h.ed.doc.clone();
let added = call(h, "annotate", json!({"notes": [
{"node": id, "text": "Locks after attempts\nFor 15 minutes"},
{"node": "sIgN IN", "text": "Keyboard support", "type": "content"}
]}));
assert_eq!(added, json!([[1, id, "rule", "Locks after attempts"], [2, id, "content", "Keyboard support"]]));
let notes = &h.ed.doc.annotations;
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].body, "For 15 minutes");
assert_eq!(notes[0].author, Author::Agent);
assert!(card_world(&h.ed.doc, &notes[1]).y >= card_world(&h.ed.doc, &notes[0]).y + CARD_H);
h.ed.undo();
assert_eq!(h.ed.doc, before);
h.ed.redo();
assert_eq!(h.ed.doc.annotations.len(), 2);
}
#[test]
fn invalid_batch_is_atomic_and_ambiguity_lists_candidates() {
let mut f = Fixture::new();
let h = &mut f.host;
let first = frame(h, "Same");
let second = frame(h, "Same");
let before = h.ed.doc.clone();
let err = exec::call(h, "annotate", &json!({"notes": [
{"node": first, "text": "valid"}, {"node": "same", "text": "ambiguous"}
]})).err().unwrap();
assert!(err.contains("ambiguous"), "{err}");
for id in [first.as_str().unwrap(), second.as_str().unwrap()] { assert!(err.contains(id), "{err}"); }
assert_eq!(h.ed.doc, before);
let err = exec::call(h, "annotate", &json!({"notes": [
{"node": first, "text": "valid"}, {"node": second, "text": "bad", "type": "missing"}
]})).err().unwrap();
for key in ["rule", "behaviour", "content", "data", "a11y", "dev"] { assert!(err.contains(key), "{err}"); }
assert_eq!(h.ed.doc, before);
}
#[test]
fn annotation_compact_full_update_delete_and_refs() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
call(h, "annotate", json!({"notes": [
{"node": id, "text": "Label\nFirst body line\nSecond body line"},
{"node": id, "text": "Other"}
]}));
let rows = call(h, "get_annotations", json!({}));
assert_eq!(rows[0], json!([1, "Sign in", "rule", "Label\nFirst body line"]));
let full = call(h, "get_annotations", json!({"full": true}));
assert_eq!(full[0]["body"], "First body line\nSecond body line");
assert_eq!(full[0]["author"], "agent");
assert_eq!(full[0]["nodeId"], id);
call(h, "update_annotation", json!({"ref": "frame Sign in #1", "text": "Edited\nNew body", "type": "data"}));
assert_eq!(call(h, "get_annotations", json!({}))[0], json!([1, "Sign in", "data", "Edited\nNew body"]));
h.ed.undo();
assert_eq!(h.ed.doc.annotations[0].label, "Label");
call(h, "update_annotation", json!({"id": full[0]["id"], "text": "é".repeat(140)}));
let rows = call(h, "get_annotations", json!({}));
assert_eq!(rows[0][3].as_str().unwrap().chars().count(), 120);
call(h, "delete_annotations", json!({"refs": ["Sign in #1", "Sign in #2"]}));
assert!(h.ed.doc.annotations.is_empty());
h.ed.undo();
assert_eq!(h.ed.doc.annotations.len(), 2);
call(h, "delete_annotations", json!({"ids": [full[0]["id"], full[1]["id"]]}));
assert!(h.ed.doc.annotations.is_empty());
}
#[test]
fn annotation_page_scope_and_subtree_filter() {
let mut f = Fixture::new();
let h = &mut f.host;
let original = h.ed.active_page;
let other = call(h, "create_page", json!({"name": "Other"}))["pageId"].clone();
let id = call(h, "create_artboard", json!({"pageId": other, "name": "Remote",
"styles": {"width": "100px", "height": "100px"}}))["id"].clone();
call(h, "annotate", json!({"pageId": other, "notes": [{"node": "remote", "text": "Remote note"}]}));
assert_eq!(h.ed.active_page, original);
assert!(call(h, "get_annotations", json!({})).as_array().unwrap().is_empty());
assert_eq!(call(h, "get_annotations", json!({"nodeId": id}))[0][3], "Remote note");
}
#[test]
fn annotation_codegen_comments_use_project_category_names() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
let unrelated = frame(h, "Other");
h.ed.design.annotation_categories = papercraft::model::annotation::default_categories();
h.ed.design.annotation_categories[0].name = "Account rule".into();
call(h, "annotate", json!({"notes": [
{"node": id, "text": "Locks for 15 minutes after 5 failed attempts.\nSecond line"},
{"node": unrelated, "text": "Unrelated"}
]}));
let expected = "// 1 Account rule: Locks for 15 minutes after 5 failed attempts.";
for format in ["inline", "react", "tailwind", "angular", "html", "egui"] {
let output = call(h, "get_jsx", json!({"nodeId": id, "format": format}));
let code = output.as_str().unwrap();
assert!(code.contains(expected), "{format}: {code}");
assert!(code.contains("// Second line"));
assert!(!code.contains("Unrelated"));
}
let md = call(h, "get_design_spec", json!({"nodeId": id}));
assert!(md.as_str().unwrap().contains(expected));
let spec = call(h, "get_design_spec", json!({"nodeId": id, "format": "json"}));
assert!(spec["referenceCode"].as_str().unwrap().contains(expected));
assert_eq!(spec["annotations"][0], expected);
}
#[test]
fn annotation_tool_classification_and_description_budget() {
use papercraft::mcp::tools;
for (name, edits) in [("annotate", true), ("get_annotations", false),
("get_annotation_template", false), ("set_annotation_template", true),
("update_annotation", true), ("delete_annotations", true)] {
assert_eq!(tools::edits_doc(name, &json!({})), edits);
let def = tools::definitions().into_iter().find(|d| d["name"] == name).unwrap();
assert!(def["description"].as_str().unwrap().split_whitespace().count() < 60);
assert_eq!(def["annotations"]["readOnlyHint"], !edits);
}
}
#[test]
fn annotation_comments_do_not_satisfy_code_verification() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
call(h, "write_html", json!({"targetNodeId": id, "html": "<div>Required visible text</div>"}));
let base = call(h, "verify_code", json!({"nodeId": id, "target": "react", "code": "<div />"}));
let commented = call(h, "verify_code", json!({"nodeId": id, "target": "react", "code": "// 1 Content: Required visible text\n<div />"}));
assert_eq!(base, commented);
}
#[test]
fn annotation_custom_keys_and_failed_edits_preserve_notes() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
let mut category = papercraft::model::annotation::default_categories().remove(0);
category.id = "custom-id".into();
category.key = "policy".into();
category.name = "Policy".into();
h.ed.design.annotation_categories = vec![category];
let mut t = papercraft::model::annotation::builtin_templates().remove(0);
t.categories[0].key = "policy".into();
h.ed.design.annotation_templates = vec![t];
let rows = call(h, "annotate", json!({"notes": [{"node": id, "text": "Policy text", "type": "POLICY"}]}));
assert_eq!(rows[0][2], "policy");
assert_eq!(h.ed.doc.annotations[0].category, "custom-id");
assert_eq!(call(h, "get_annotations", json!({}))[0][2], "policy");
let before = h.ed.doc.clone();
let err = exec::call(h, "update_annotation", &json!({"ref": "Sign in #1", "text": "Changed", "type": "rule"})).err().unwrap();
assert!(err.contains("policy"));
assert_eq!(h.ed.doc, before);
assert!(exec::call(h, "delete_annotations", &json!({"refs": ["Sign in #1", "Sign in #99"]})).is_err());
assert_eq!(h.ed.doc, before);
let code = call(h, "get_jsx", json!({"nodeId": id}));
assert!(code.as_str().unwrap().starts_with("// 1 Policy: Policy text"));
}
#[test]
fn builtin_templates_match_spec() {
use papercraft::model::annotation::builtin_templates;
let t = builtin_templates();
let keys = |i: usize| t[i].enabled_keys().join(",");
assert_eq!(t.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
["Product spec", "Dev handoff", "Accessibility review", "Content review", "Minimal"]);
assert_eq!(keys(0), "rule,behaviour,content,data");
assert_eq!(keys(1), "behaviour,data,dev,a11y");
assert_eq!(t[2].categories[0].fields, ["Issue", "WCAG", "Fix"]);
assert_eq!(t[3].categories[0].fields, ["Tone", "Max length"]);
assert_eq!(keys(4), "rule");
assert!(t.iter().flat_map(|t| &t.categories).all(|c| !c.prompt.is_empty()));
}
#[test]
fn page_override_beats_project_default_and_undoes() {
let mut f = Fixture::new();
let h = &mut f.host;
assert_eq!(h.ed.active_template(0).id, "product-spec");
h.ed.set_default_template(Some("dev-handoff")).unwrap();
assert_eq!(h.ed.active_template(0).id, "dev-handoff");
h.ed.set_page_template(0, Some("minimal")).unwrap();
assert_eq!(h.ed.active_template(0).id, "minimal");
h.ed.undo();
assert_eq!(h.ed.active_template(0).id, "dev-handoff");
h.ed.undo();
assert_eq!(h.ed.active_template(0).id, "product-spec");
h.ed.redo();
h.ed.redo();
assert_eq!(h.ed.active_template(0).id, "minimal");
assert!(h.ed.set_page_template(0, Some("nope")).is_err());
}
#[test]
fn old_page_bytes_unchanged_and_override_roundtrips() {
use papercraft::project::format::{page_from_json_modes, page_to_json_modes, page_to_json};
use papercraft::variables::{PAGE_TEMPLATE_KEY, PageModes};
let mut f = Fixture::new();
let h = &mut f.host;
let page = h.ed.page_data(0);
let plain = page_to_json(&page);
assert!(!plain.contains("annotationTemplate"));
let (p, modes) = page_from_json_modes(&plain).unwrap();
assert_eq!(page_to_json_modes(&p, &modes), plain);
let mut m = PageModes::new();
m.insert(PAGE_TEMPLATE_KEY.into(), "minimal".into());
let s = page_to_json_modes(&page, &m);
assert!(s.contains("\"annotationTemplate\": \"minimal\""));
assert!(!s.contains("collectionModes"));
let (p, modes) = page_from_json_modes(&s).unwrap();
assert_eq!(modes, m);
assert_eq!(page_to_json_modes(&p, &modes), s);
}
#[test]
fn annotate_rejects_disabled_type_and_keeps_fields() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
let before = h.ed.doc.clone();
let err = exec::call(h, "annotate", &json!({"notes": [{"node": id, "text": "x", "type": "a11y"}]}))
.err().unwrap();
assert!(err.contains("rule, behaviour, content, data") && err.contains("Product spec"), "{err}");
assert_eq!(h.ed.doc, before);
call(h, "set_annotation_template", json!({"template": "accessibility review"}));
call(h, "annotate", json!({"notes": [{"node": id, "type": "a11y",
"text": "Low contrast\nIssue: grey on white\nWCAG: 1.4.3\nFix: darken"}]}));
assert_eq!(h.ed.doc.annotations[0].body, "Issue: grey on white\nWCAG: 1.4.3\nFix: darken");
}
#[test]
fn set_get_template_round_trip_and_scopes() {
let mut f = Fixture::new();
let h = &mut f.host;
let t = call(h, "get_annotation_template", json!({}));
assert_eq!(t["template"], "Product spec");
assert_eq!(t["types"].as_array().unwrap().len(), 4);
assert_eq!(t["types"][0][0], "rule");
call(h, "set_annotation_template", json!({"template": "content-review", "scope": "project"}));
let t = call(h, "get_annotation_template", json!({}));
assert_eq!(t["template"], "Content review");
assert_eq!(t["types"], json!([["content", t["types"][0][1], ["Tone", "Max length"]]]));
call(h, "set_annotation_template", json!({"template": "Minimal"}));
assert_eq!(call(h, "get_annotation_template", json!({}))["template"], "Minimal");
assert!(exec::call(h, "set_annotation_template", &json!({"template": "zzz"})).err().unwrap().contains("Minimal"));
assert!(exec::call(h, "set_annotation_template", &json!({"template": "minimal", "scope": "x"})).is_err());
}
#[test]
fn template_edits_are_undoable() {
let mut f = Fixture::new();
let h = &mut f.host;
let base = h.ed.design.clone();
let new = h.ed.duplicate_template("minimal").unwrap();
assert_eq!(h.ed.design.templates().len(), 6);
let mut t = h.ed.design.template_by_ref(&new).unwrap();
t.name = "Mine".into();
t.categories[0].prompt = "custom".into();
h.ed.upsert_template(t).unwrap();
assert_eq!(h.ed.design.template_by_ref("mine").unwrap().categories[0].prompt, "custom");
h.ed.reset_template("product-spec").unwrap();
assert!(h.ed.reset_template(&new).is_err());
h.ed.set_default_template(Some(&new)).unwrap();
h.ed.delete_template(&new).unwrap();
assert_eq!(h.ed.design.annotation_template, None);
assert_eq!(h.ed.design.templates().len(), 5);
h.ed.undo();
assert_eq!(h.ed.design.annotation_template.as_deref(), Some(new.as_str()));
assert_eq!(h.ed.design.templates().len(), 6);
h.ed.undo();
h.ed.undo();
h.ed.undo();
h.ed.undo();
assert_eq!(h.ed.design, base);
}
#[test]
fn update_annotation_rejects_type_disabled_by_template() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
call(h, "annotate", json!({"notes": [{"node": id, "text": "x"}]}));
let before = h.ed.doc.clone();
let err = exec::call(h, "update_annotation", &json!({"ref": "Sign in #1", "type": "a11y"}))
.err().unwrap();
assert!(err.contains("rule, behaviour, content, data") && err.contains("Product spec"), "{err}");
assert_eq!(h.ed.doc, before);
call(h, "update_annotation", json!({"ref": "Sign in #1", "type": "data"}));
assert_eq!(h.ed.doc.annotations[0].category, "data");
}
+69
View File
@@ -0,0 +1,69 @@
//! The annotations overlay draws a page with notes in every mode without panicking.
use egui::{Pos2, Rect, Vec2};
use papercraft::editor::Editor;
use papercraft::geom::Rect as WRect;
use papercraft::model::annotation::Annotation;
use papercraft::model::{Node, NodeId, NodeKind};
use papercraft::ui::annotations_canvas::Mode;
use papercraft::ui::{self, UiState};
fn frame(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1280.0, 800.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
ui::draw(ui, ed, st);
});
out.textures_delta.clear();
}
#[test]
fn draws_in_every_mode() {
let ctx = egui::Context::default();
ui::setup_style(&ctx);
let mut ed = Editor::default();
let id = ed.add_node(
Node::new(NodeId(0), NodeKind::Rect, WRect::new(100.0, 100.0, 300.0, 200.0)),
None,
);
let mut a = Annotation::new(Some(id), "Rule", "rule");
a.body = "A long body that wraps over several lines so the card has to clip it to two.".into();
a.position = papercraft::geom::V2 { x: 340.0, y: 0.0 };
ed.add_annotation(a);
ed.add_annotation(Annotation::new(None, "Loose", "a11y"));
let mut st = UiState::default();
for mode in [Mode::Full, Mode::Badges, Mode::Hidden] {
st.annotations.mode = Some(mode);
for _ in 0..2 {
frame(&ctx, &mut ed, &mut st);
}
}
st.annotations.mode = Some(Mode::Full);
st.annotations.hidden_categories.insert("rule".into());
st.annotations.collapsed.insert(ed.doc.annotations[1].id);
frame(&ctx, &mut ed, &mut st);
}
#[test]
fn every_default_category_icon_has_a_painted_icon() {
for c in papercraft::model::annotation::default_categories() {
assert!(papercraft::theme::icon_by_name(&c.icon).is_some(), "{}", c.icon);
}
}
/// Smoke: each new icon paints shapes.
#[test]
fn new_icons_paint() {
use papercraft::theme::{Icon, paint_icon};
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
for (i, ic) in [Icon::Scale, Icon::Database, Icon::Accessibility, Icon::Code, Icon::Zap].into_iter().enumerate() {
let r = Rect::from_min_size(Pos2::new(i as f32 * 40.0, 0.0), Vec2::splat(32.0));
paint_icon(ui.painter(), r, ic, egui::Color32::WHITE);
}
});
assert!(!out.shapes.is_empty());
out.textures_delta.clear();
}
+10
View File
@@ -0,0 +1,10 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"mcpCapabilities":{"http":true}}}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1"}}
> "method":"session/prompt"
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call","toolCallId":"t1","title":"Run ls","kind":"execute","status":"in_progress","rawInput":{"command":"ls"}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"working"}}}}
< {"jsonrpc":"2.0","id":100,"method":"session/request_permission","params":{"sessionId":"sess-1","toolCall":{"toolCallId":"t1","title":"Run ls","kind":"execute"},"options":[{"optionId":"allow-once","name":"Allow","kind":"allow_once"}]}}
>> "session/cancel" "id":100
< {"jsonrpc":"2.0","id":2,"result":{"stopReason":"cancelled"}}
+4
View File
@@ -0,0 +1,4 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"mcpCapabilities":{"http":true}}}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"Authentication required"}}
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Fake ACP agent for src/chat/acp.rs tests: sh acp_fake_agent.sh FIXTURE CLIENT_LOG
# Fixture lines: "< json" = send to the client; "> text" = the next line from the client must
# contain text; ">> a b" = the next two lines, in either order, contain a and b. Every line the
# client sends is appended to CLIENT_LOG.
FIX=$1; LOG=$2
fail() { echo "fake agent: $*" >&2; exit 9; }
has() { case "$1" in *"$2"*) return 0 ;; esac; return 1; }
while IFS= read -r l <&3; do
case "$l" in
'<'*) printf '%s\n' "${l#< }" ;;
'>>'*)
set -- ${l#>>}
IFS= read -r a || fail "stdin closed"
IFS= read -r b || fail "stdin closed"
printf '%s\n%s\n' "$a" "$b" >> "$LOG"
for p in "$@"; do has "$a" "$p" || has "$b" "$p" || fail "expected $p in: $a / $b"; done ;;
'>'*)
IFS= read -r got || fail "stdin closed"
printf '%s\n' "$got" >> "$LOG"
has "$got" "${l#> }" || fail "expected ${l#> } got $got" ;;
esac
done 3< "$FIX"
+20
View File
@@ -0,0 +1,20 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false,"promptCapabilities":{"image":true},"mcpCapabilities":{"http":true,"sse":false}},"authMethods":[]}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1"}}
> "method":"session/prompt"
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hmm"}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hel"}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"m1","content":{"type":"text","text":"lo"}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"some_future_update","x":1}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call","toolCallId":"t1","title":"mcp__papercraft__get_basic_info","kind":"other","status":"pending","rawInput":{}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"t1","status":"in_progress"}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"t1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"ok\":true}"}}]}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call","toolCallId":"t2","title":"ls","kind":"execute","status":"in_progress","rawInput":{"path":"."}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"t2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"denied"}}]}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"plan","entries":[{"content":"A","priority":"high","status":"completed"},{"content":"B","priority":"low","status":"weird"},{"priority":"low"}]}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"usage_update","used":5000,"size":200000,"cost":{"amount":0.5,"currency":"USD"}}}}
< {"jsonrpc":"2.0","id":50,"method":"fs/read_text_file","params":{"sessionId":"sess-1","path":"/x"}}
> "code":-32601
< not json at all
< {"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn","usage":{"inputTokens":7,"outputTokens":3}}}
+9
View File
@@ -0,0 +1,9 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true}}}}
> "method":"session/load"
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"old","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"OLD QUESTION"}}}}
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"old","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OLD ANSWER"}}}}
< {"jsonrpc":"2.0","id":1,"result":{}}
> "method":"session/prompt"
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"old","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh"}}}}
< {"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}
+8
View File
@@ -0,0 +1,8 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true}}}}
> "method":"session/load"
< {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"no such session"}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":2,"result":{"sessionId":"sess-new"}}
> "method":"session/prompt"
< {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
+14
View File
@@ -0,0 +1,14 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"mcpCapabilities":{"http":true}}}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1"}}
> "method":"session/prompt"
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call","toolCallId":"t-ok","title":"mcp__papercraft__get_basic_info","kind":"other","status":"pending","rawInput":{}}}}
< {"jsonrpc":"2.0","id":100,"method":"session/request_permission","params":{"sessionId":"sess-1","toolCall":{"toolCallId":"t-ok","title":"mcp__papercraft__get_basic_info"},"options":[{"optionId":"allow-always","name":"Always","kind":"allow_always"},{"optionId":"allow-once","name":"Allow","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}
> "id":100
< {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"tool_call","toolCallId":"t-del","title":"mcp__papercraft__delete_pages","kind":"other","status":"pending","rawInput":{"pageIds":["p1"]}}}}
< {"jsonrpc":"2.0","id":101,"method":"session/request_permission","params":{"sessionId":"sess-1","toolCall":{"toolCallId":"t-del","title":"mcp__papercraft__delete_pages"},"options":[{"optionId":"allow-once","name":"Allow","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}
> "id":101
< {"jsonrpc":"2.0","id":102,"method":"session/request_permission","params":{"sessionId":"sess-1","toolCall":{"toolCallId":"t-sh","title":"Run ls","kind":"execute","rawInput":{"command":"ls"}},"options":[{"optionId":"allow-once","name":"Allow","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}
> "id":102
< {"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}
+6
View File
@@ -0,0 +1,6 @@
> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1"}}
> "method":"session/prompt"
< {"jsonrpc":"2.0","id":2,"result":{"stopReason":"max_tokens"}}