Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e32acfeff | |||
| 999e9cbc36 | |||
| c71f9f3dc2 | |||
| d3e0351d05 | |||
| 4108ecdce9 | |||
| 4bf3e2a1df | |||
| e9da5eeba5 | |||
| d934f4ca89 | |||
| 5b54f9a7be |
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.7.1"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.7.1"
|
||||
version = "0.7.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
|
||||
@@ -76,6 +76,30 @@ 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-25: 0.7.3 (QA sub-agent, helper agents, auto mode, mid-turn messages, isolated dev settings)
|
||||
|
||||
* QA sub-agent: Claude Code chats always get a Sonnet `qa` agent that lints and screenshots built frames and returns only findings, so screenshots stay out of the main context.
|
||||
* Helper agents (`src/mcp/delegate.rs`, Settings, Chat, Helper): `delegate` hands `qa`, `read` or `build` tasks to a cheaper agent CLI (pi with MiniMax M3.1 Flash, opencode, an Ollama-backed agent, any command taking `{prompt}`). It drives PaperCraft through `papercraft-cli`, runs off the UI thread, and only its last 6,000 characters come back. With a helper set, the chat agent orchestrates: keeps the brief and taste, delegates review, bulk reading and fully specified edits.
|
||||
* Dev config isolation: `PAPERCRAFT_HOME` also isolates the chat settings (`$PAPERCRAFT_HOME/.config`), so a second copy for testing never touches the installed app's settings.
|
||||
* Live check in an isolated copy: `delegate` ran the pi helper (MiniMax M3.1 Flash) as QA, a message added mid-turn went Waiting then Seen, and the agent drafted #13.
|
||||
* Fixes #10, #11, #12, #13:
|
||||
* #10: `move_nodes` to another page keeps mains' component keys and relinks every instance to moved nodes; new `rename_file` renames a project.
|
||||
* #11: swapped nested instances persist as swap overrides (`InstanceLink.swap`), preserving component identity across outer component updates and copies.
|
||||
* #12: `move_nodes` to another page takes the moved nodes' annotations along; detached notes resolve as `Detached #n`.
|
||||
* #13: `get_computed_styles` reports a text colour bound to a variable as `var(--name)`.
|
||||
* Auto mode (the composer's Auto chip): when a turn ends on `ask_user` questions, the first (recommended) option of each is sent and the chat goes on, until Stop or a turn hits its budget. The agent is told to put its recommendation first, default what only the user can know, and end each turn with next steps.
|
||||
* The Present button uses a play icon drawn like the icons beside it. Composer chips wrap instead of running under Stop and Add. Top bar: the agent pill drops its text when it would run under the buttons on the right.
|
||||
* While a chat turn works, the composer's Add (or Enter) queues a message (`src/mcp/steer.rs`). It rides on the agent's next PaperCraft tool result for that chat, so every backend reads it mid-turn; the composer shows each as Waiting or Seen. What no tool call picked up is sent as the next message when the turn ends, or goes back into the box after Stop.
|
||||
|
||||
### 2026-10-24: 0.7.2 (UI kits, code links, issues from the app, design playbook)
|
||||
|
||||
* Code links (Figma Code Connect style): a main component carries its library import and a React and an egui template (`set_code_link`). The React, Tailwind, inline JSX and egui exports write linked instances as that code, with overrides filled in and imports gathered once; the AI spec lists them as Library Components with a "use it, do not rebuild it" rule and stops describing their inner layers.
|
||||
* UI kits (`src/ui_kits.rs`, `apply_ui_kit`): shadcn/ui, Ant Design, React Bits and egui, each with Light and Dark variables, tokens and components in its look and density, all linked. The chat brief asks for the code library and starts from its kit.
|
||||
* Issues from the app (`src/issues.rs`): the agent's `report_issue` draft card (Send or Discard) and a Report a PaperCraft issue form, filed on the repository in Settings (token in the keychain, else the `tea` login), de-duplicated by title against open and closed issues.
|
||||
* design-playbook in the chat: with the `playbook` CLI installed, the brief asks whether to follow the playbook and the agent looks rules up with `design_rules`, citing ids in its report.
|
||||
* Top bar: project name and page picker on one centred row; the two-row title had outgrown the bar and covered the canvas.
|
||||
* Evidence: `src/ui_kits.rs`, `src/issues.rs`, `src/mcp/codegen.rs` (`linked_components_export_as_library_code`), `specs/ui-kits/spec.md`.
|
||||
|
||||
### 2026-10-24: 0.7.1 (rename fix)
|
||||
|
||||
* Enter, Escape and clicking away end a rename again (projects on the home screen, library assets, versions). The fields took focus back before checking whether they had lost it.
|
||||
|
||||
|
Before Width: | Height: | Size: 718 KiB After Width: | Height: | Size: 720 KiB |
|
Before Width: | Height: | Size: 673 KiB After Width: | Height: | Size: 675 KiB |
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 636 KiB After Width: | Height: | Size: 638 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 837 KiB After Width: | Height: | Size: 838 KiB |
|
Before Width: | Height: | Size: 691 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 712 KiB |
|
Before Width: | Height: | Size: 882 KiB After Width: | Height: | Size: 885 KiB |
@@ -59,7 +59,7 @@ function Get-PeHeader([string] $Path) {
|
||||
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
|
||||
}
|
||||
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
|
||||
foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe')) {
|
||||
foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe', 'papercraft-relay.exe')) {
|
||||
$exePath = Join-Path $Bin $name
|
||||
if (Test-Path $exePath) {
|
||||
$h = Get-PeHeader $exePath
|
||||
@@ -71,13 +71,13 @@ foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe'
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
Copy-Item (Join-Path $Bin 'papercraft.exe'), (Join-Path $Bin 'papercraft-cli.exe'), (Join-Path $Bin 'papercraft-mcp.exe') $Stage
|
||||
Copy-Item (Join-Path $Bin 'papercraft.exe'), (Join-Path $Bin 'papercraft-cli.exe'), (Join-Path $Bin 'papercraft-mcp.exe'), (Join-Path $Bin 'papercraft-relay.exe') $Stage
|
||||
foreach ($f in 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md', 'README.md') {
|
||||
$p = Join-Path $Root $f
|
||||
if (Test-Path $p) { Copy-Item $p $Stage }
|
||||
}
|
||||
|
||||
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'papercraft.exe') (Join-Path $Stage 'papercraft-cli.exe') (Join-Path $Stage 'papercraft-mcp.exe')
|
||||
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'papercraft.exe') (Join-Path $Stage 'papercraft-cli.exe') (Join-Path $Stage 'papercraft-mcp.exe') (Join-Path $Stage 'papercraft-relay.exe')
|
||||
|
||||
# ---- MSI ---------------------------------------------------------------------------------------
|
||||
$Msi = Join-Path $Dist "papercraft-$Version-windows-$Arch.msi"
|
||||
@@ -119,9 +119,22 @@ $CliZip = Join-Path $Dist "papercraft-cli-$Version-windows-$Arch.zip"
|
||||
Remove-Item -Force $CliZip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $CliPortable -DestinationPath $CliZip
|
||||
|
||||
# ---- relay zip ---------------------------------------------------------------------------------
|
||||
$RelayPortable = Join-Path $TargetDir "windows-package\papercraft-relay-$Version-windows-$Arch"
|
||||
Remove-Item -Recurse -Force $RelayPortable -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $RelayPortable | Out-Null
|
||||
Copy-Item (Join-Path $Stage 'papercraft-relay.exe') $RelayPortable
|
||||
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md') {
|
||||
$p = Join-Path $Root $f
|
||||
if (Test-Path $p) { Copy-Item $p $RelayPortable }
|
||||
}
|
||||
$RelayZip = Join-Path $Dist "papercraft-relay-$Version-windows-$Arch.zip"
|
||||
Remove-Item -Force $RelayZip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $RelayPortable -DestinationPath $RelayZip
|
||||
|
||||
# Smoke test
|
||||
Invoke-Native 'papercraft-cli --help' { & (Join-Path $Stage 'papercraft-cli.exe') --help | Out-Null }
|
||||
|
||||
$artifacts = @($Zip, $CliZip)
|
||||
$artifacts = @($Zip, $CliZip, $RelayZip)
|
||||
if (Test-Path $Msi) { $artifacts += $Msi }
|
||||
Get-Item $artifacts | Format-Table Name, Length
|
||||
|
||||
@@ -111,9 +111,17 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css, json or tailwind), `set_variables` (`collection`, `type`, `mode`, `alias`), `create_collection`, `delete_variables`, `set_variable_mode`, `bind_variable` |
|
||||
| Reuse | `create_component`, `create_instance`, `swap_instance`, `get_components`, `<x-paper-instance>` |
|
||||
| Reuse | `create_component`, `create_instance`, `swap_instance` (also a nested badge inside a row instance: a swap that survives main edits), `get_components`, `<x-paper-instance>` |
|
||||
| Project | `rename_file {name}` (open project stays open), `move_nodes {toPageId}` keeps instances linked and notes attached |
|
||||
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
|
||||
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
|
||||
| Design in a library's look, hand off its code | `apply_ui_kit` (shadcn, antd, reactbits, egui), then `<x-paper-instance>` of its components; `set_code_link` for a component of your own; `get_jsx` / `get_design_spec` then write `<Button variant="outline">` instead of divs |
|
||||
| Avatars, more icons, photos, flags, QR | `<x-plugin use="avataaars\|dicebear\|iconify\|picsum\|flags\|qr" …/>` inside `write_html` (plugins the user turned on) |
|
||||
| References and art | `fetch_reference_images`, `zoom_image`, `generate_image` (when the user turned image generation on) |
|
||||
| Ask the user | `ask_user` (clickable answers; end your turn after it) |
|
||||
| Hand work to the helper agent | `delegate` (`role` qa, read or build, a self-contained `task`; only when Settings, Chat, Helper is set) |
|
||||
| Design rules (design-playbook) | `design_rules` (`query` or `rule` id; needs the `playbook` CLI; cite ids in the report) |
|
||||
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
|
||||
| Check quality | `lint_design` |
|
||||
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
|
||||
| Point at things for the user | `select_nodes` (`zoom: true`) |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# UI kits and code links
|
||||
|
||||
## Why
|
||||
|
||||
The Code & AI Handoff rebuilds every layer as `<div>`s with Tailwind classes, and the AI spec
|
||||
describes boxes. A team that ships with shadcn/ui, Ant Design or React Bits then re-maps each
|
||||
box to its library by hand, and the design never looked like that library in the first place.
|
||||
For egui the same holds: the export draws frames where the app would call `ui.button`.
|
||||
|
||||
## What
|
||||
|
||||
1. **Code links** (like Figma's Code Connect): a main component can carry a `CodeLink`: the
|
||||
kit, the import line, and a usage template per target, React (`<Button variant="outline">{Label}</Button>`)
|
||||
and egui (`ui.button({Label})`). `{Name}` in a template is the text of the layer named
|
||||
`Name` in the instance (its override when there is one). Set with the `set_code_link` tool
|
||||
or by a kit.
|
||||
2. **Kits** (data, `src/ui_kits.rs`): shadcn/ui, Ant Design, React Bits (animated text,
|
||||
backgrounds and a dock, on top of any styling) and egui. Each kit has its tokens (colors in
|
||||
light and dark, radius, control height, font size) and a set of components: the markup that
|
||||
draws it in that library's look and density, and its code link. `apply_ui_kit` builds a
|
||||
"UI Kit / <name>" page with the variables, tokens and linked components, ready to place
|
||||
with `<x-paper-instance>`.
|
||||
3. **Handoff**: React and Tailwind exports emit a linked instance as its template (with the
|
||||
imports gathered at the top) instead of its layers; the egui export does the same with the
|
||||
egui template. The AI spec gets a "Library components" section (component, import, usage,
|
||||
where it is used) and the rule to use them, not to rebuild them.
|
||||
4. **Chat**: the brief asks which library the code will use; the agent applies that kit
|
||||
before building, so screens are made of linked components from the start.
|
||||
|
||||
## Not now
|
||||
|
||||
Variant props beyond text (a variant is its own component with its own template), Vue and
|
||||
Svelte templates, importing a kit from a package registry.
|
||||
|
||||
## Acceptance
|
||||
|
||||
* A linked Button instance with the override `Label: "Save"` exports in React as
|
||||
`<Button variant="default">Save</Button>` with `import { Button } from "@/components/ui/button"`
|
||||
once at the top, and in egui as its egui call.
|
||||
* An unlinked frame exports as before.
|
||||
* `apply_ui_kit({kit:"shadcn"})` makes a page with the kit's variables and its components, each
|
||||
a main component with a code link.
|
||||
* The AI spec of a screen with linked instances lists them with import and usage.
|
||||
@@ -1485,17 +1485,21 @@ impl App {
|
||||
}
|
||||
for c in calls {
|
||||
// Network and Codex work runs off the editor thread; it only reads the project folder.
|
||||
if [crate::mcp::refs::TOOL, crate::mcp::imagegen::TOOL].contains(&c.name.as_str()) {
|
||||
// The helper calls back into this app through papercraft-cli, so it must not hold the UI thread.
|
||||
if [crate::mcp::refs::TOOL, crate::mcp::imagegen::TOOL, crate::mcp::delegate::TOOL].contains(&c.name.as_str()) {
|
||||
let dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
let chat = (c.client.name == crate::mcp::CHAT_CLIENT).then(|| c.client.session.clone());
|
||||
let provider = self.st.settings.chat.image_provider();
|
||||
let helper = self.st.settings.chat.helper_command.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = if c.name == crate::mcp::refs::TOOL {
|
||||
let r = if c.name == crate::mcp::delegate::TOOL {
|
||||
crate::mcp::delegate::run(&helper, chat.as_deref(), &c.args)
|
||||
} else if c.name == crate::mcp::refs::TOOL {
|
||||
crate::mcp::refs::fetch(dir.as_deref(), chat.as_deref(), &c.args)
|
||||
} else {
|
||||
crate::mcp::imagegen::generate(dir.as_deref(), chat.as_deref(), &provider, &c.args)
|
||||
};
|
||||
let _ = c.reply.send(r);
|
||||
let _ = c.reply.send(crate::mcp::steer::attach(&c.client, r));
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1511,7 +1515,7 @@ impl App {
|
||||
if let Err(e) = &r {
|
||||
log::info!("mcp {}: {e}", c.name);
|
||||
}
|
||||
let _ = c.reply.send(r);
|
||||
let _ = c.reply.send(crate::mcp::steer::attach(&c.client, r));
|
||||
}
|
||||
self.st.project_name = self.session.project_name();
|
||||
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
@@ -1564,6 +1568,11 @@ impl crate::mcp::exec::Host for App {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rename_project(&mut self, name: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dir = self.session.rename_open(&mut self.ed, name)?;
|
||||
self.st.project_name = self.session.project_name();
|
||||
Ok(dir)
|
||||
}
|
||||
fn gpu(&mut self) -> Option<Headless> {
|
||||
self.offscreen().or_else(|| Headless::new().ok())
|
||||
}
|
||||
|
||||
@@ -179,7 +179,11 @@ mod process {
|
||||
/// Research runs in a Haiku sub-agent: its searches and fetches stay out of the main
|
||||
/// context, which gets only the kept references (a research step cost $0.03 instead of the
|
||||
/// $1.71 it cost in a long main session).
|
||||
const RESEARCHER: &str = r#"{"researcher": {"description": "Finds interface references on the web and brings the useful ones in. Use it for all reference research.", "prompt": "You research UI references for a designer. Search the web, then call mcp__papercraft__fetch_reference_images once with the best image or page URLs (nicelydone.club/apps/<app> for SaaS web apps, banani.co/references/apps and screenlane.com for mobile, App Store and Google Play listings; Dribbble, Behance and Figma Community block fetching). Look at what came back and keep only images that show an interface like the one asked for: no logos, icons, social cards or illustrations. If fewer than two qualify, search once more. Reply with only the kept images, one line each: the path, then what to take from it (layout, density, color, type).", "tools": ["WebSearch", "WebFetch", "mcp__papercraft__fetch_reference_images"], "model": "haiku"}}"#;
|
||||
const RESEARCHER: &str = r#"{"description": "Finds interface references on the web and brings the useful ones in. Use it for all reference research.", "prompt": "You research UI references for a designer. Search the web, then call mcp__papercraft__fetch_reference_images once with the best image or page URLs (nicelydone.club/apps/<app> for SaaS web apps, banani.co/references/apps and screenlane.com for mobile, App Store and Google Play listings; Dribbble, Behance and Figma Community block fetching). Look at what came back and keep only images that show an interface like the one asked for: no logos, icons, social cards or illustrations. If fewer than two qualify, search once more. Reply with only the kept images, one line each: the path, then what to take from it (layout, density, color, type).", "tools": ["WebSearch", "WebFetch", "mcp__papercraft__fetch_reference_images"], "model": "haiku"}"#;
|
||||
|
||||
/// Review runs in a Sonnet sub-agent: its screenshots and lint output stay out of the main
|
||||
/// context (every image there is re-read on each later step), which gets only the findings.
|
||||
const QA: &str = r#"{"description": "Reviews built screens: lints and screenshots them and reports only what is wrong. Use it for the end-of-turn check instead of taking screenshots yourself.", "prompt": "You are the QA reviewer for a design made in PaperCraft. You get the frames to check and the brief (size, density, palette, library). Call mcp__papercraft__lint_design without fix, then one mcp__papercraft__get_screenshot per frame, and get_tree_summary or get_computed_styles only to confirm a suspicion. Check: overflow and clipped text, misalignment and uneven spacing, off-palette or literal colors, contrast, target sizes for the density, empty or placeholder content, repeated look-alikes that should be instances. Do not edit anything. Reply with at most 12 findings, most severe first, one line each: frame or node id, the problem, the fix. Reply 'No findings' when it is clean.", "tools": ["mcp__papercraft__lint_design", "mcp__papercraft__get_screenshot", "mcp__papercraft__get_tree_summary", "mcp__papercraft__find_nodes", "mcp__papercraft__get_node_info", "mcp__papercraft__get_computed_styles"], "model": "sonnet"}"#;
|
||||
|
||||
/// Measured in specs/chat-tokens: 42.7k prompt tokens down to 20.6k. Bash, Edit, Write and
|
||||
/// the web tools need a permission `-p` never grants, so they only cost tokens.
|
||||
@@ -191,21 +195,23 @@ mod process {
|
||||
let mut a: Vec<String> = [
|
||||
"-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
|
||||
"--include-partial-messages", "--mcp-config", &mcp.to_string(), "--strict-mcp-config",
|
||||
"--allowedTools", "mcp__papercraft__*",
|
||||
"--allowedTools",
|
||||
]
|
||||
.map(String::from)
|
||||
.into();
|
||||
if cfg.research {
|
||||
let i = a.iter().position(|x| x == "--allowedTools").unwrap() + 1;
|
||||
a[i].push_str(",WebSearch,WebFetch,Agent");
|
||||
a.extend(["--agents".into(), RESEARCHER.to_string()]);
|
||||
}
|
||||
let mut agents = serde_json::json!({"qa": serde_json::from_str::<Value>(QA).unwrap()});
|
||||
let extra = if cfg.research {
|
||||
agents["researcher"] = serde_json::from_str(RESEARCHER).unwrap();
|
||||
",WebSearch,WebFetch,Agent"
|
||||
} else {
|
||||
",Agent"
|
||||
};
|
||||
a.push(format!("mcp__papercraft__*{extra}"));
|
||||
a.extend(["--agents".into(), agents.to_string()]);
|
||||
if cfg.lean {
|
||||
// Without ToolSearch the CLI loads every PaperCraft tool schema up front.
|
||||
a.extend(LEAN_ARGS.map(String::from));
|
||||
if cfg.research {
|
||||
a.last_mut().unwrap().push_str(",WebSearch,WebFetch,Agent");
|
||||
}
|
||||
a.last_mut().unwrap().push_str(extra);
|
||||
}
|
||||
if let Some(f) = system_file {
|
||||
a.extend(["--append-system-prompt-file".into(), f.display().to_string()]);
|
||||
@@ -414,7 +420,10 @@ mod process {
|
||||
let a = args(&cfg, Some(Path::new("/tmp/sys.txt")));
|
||||
let at = |f: &str| a[a.iter().position(|x| x == f).unwrap() + 1].as_str();
|
||||
assert_eq!(at("--output-format"), "stream-json");
|
||||
assert_eq!(at("--allowedTools"), "mcp__papercraft__*");
|
||||
assert_eq!(at("--allowedTools"), "mcp__papercraft__*,Agent");
|
||||
let agents: Value = serde_json::from_str(at("--agents")).unwrap();
|
||||
assert_eq!(agents["qa"]["model"], "sonnet");
|
||||
assert!(agents.get("researcher").is_none(), "research is off");
|
||||
assert_eq!(at("--append-system-prompt-file"), "/tmp/sys.txt");
|
||||
assert!(!a.iter().any(|x| x == "SYS" || x == "--append-system-prompt"), "the prompt text is not in argv");
|
||||
assert_eq!(at("--model"), "m");
|
||||
@@ -426,7 +435,7 @@ mod process {
|
||||
assert!(!a.contains(&"--tools".to_string()), "not lean: the user's own setup");
|
||||
let lean = args(&TurnConfig { lean: true, effort: Some("high".into()), ..cfg }, None);
|
||||
let at = |f: &str| lean[lean.iter().position(|x| x == f).unwrap() + 1].as_str();
|
||||
assert_eq!(at("--tools"), "Read,TodoWrite,ToolSearch", "ToolSearch keeps the PaperCraft schemas deferred");
|
||||
assert_eq!(at("--tools"), "Read,TodoWrite,ToolSearch,Agent", "ToolSearch keeps the PaperCraft schemas deferred");
|
||||
assert_eq!(at("--setting-sources"), "project,local");
|
||||
assert_eq!(at("--effort"), "high");
|
||||
assert!(lean.contains(&"--disable-slash-commands".to_string()));
|
||||
@@ -436,6 +445,7 @@ mod process {
|
||||
assert_eq!(at("--allowedTools"), "mcp__papercraft__*,WebSearch,WebFetch,Agent", "allowed, so -p does not deny them");
|
||||
let agents: Value = serde_json::from_str(at("--agents")).unwrap();
|
||||
assert_eq!(agents["researcher"]["model"], "haiku");
|
||||
assert_eq!(agents["qa"]["model"], "sonnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -46,15 +46,16 @@ fn components(ed: &Editor) -> Vec<Comp> {
|
||||
for page in 0..ed.pages.len() {
|
||||
for n in ed.page_doc(page).nodes.values() {
|
||||
let Some(l) = &n.instance else { continue };
|
||||
match &l.source {
|
||||
None if n.id != l.main => *page_uses.entry((page, l.main)).or_default() += 1,
|
||||
let (main, source) = l.target();
|
||||
match source {
|
||||
None if n.id != main => *page_uses.entry((page, main)).or_default() += 1,
|
||||
None => {}
|
||||
Some(Source::Page(p)) => {
|
||||
if let Some(i) = ed.pages.iter().position(|s| s.id == *p) {
|
||||
*page_uses.entry((i, l.main)).or_default() += 1;
|
||||
*page_uses.entry((i, main)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Some(Source::Library(lib)) => *lib_uses.entry((lib.as_str(), l.main)).or_default() += 1,
|
||||
Some(Source::Library(lib)) => *lib_uses.entry((lib.as_str(), main)).or_default() += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,9 +506,10 @@ impl Editor {
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
// A swapped nested instance follows the component it was swapped to.
|
||||
n.instance.as_ref().is_some_and(|l| {
|
||||
matches!(&l.source, Some(Source::Page(p)) if *p == src_id)
|
||||
&& mains.contains(&l.main)
|
||||
let (main, source) = l.swap.as_ref().map_or((l.main, &l.source), |s| (s.main, &s.source));
|
||||
matches!(source, Some(Source::Page(p)) if *p == src_id) && mains.contains(&main)
|
||||
})
|
||||
})
|
||||
.map(|n| n.id)
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::geom::{Rect, V2, v2};
|
||||
use crate::history::Tx;
|
||||
use crate::layout;
|
||||
use crate::model::{
|
||||
AutoLayout, Direction, Document, InstanceLink, Node, NodeId, NodeKind, Sizing, Source,
|
||||
AutoLayout, Direction, Document, InstanceLink, Node, NodeId, NodeKind, Sizing, Source, Swap,
|
||||
};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
@@ -162,6 +162,7 @@ pub fn instantiate_from(
|
||||
main,
|
||||
overrides: Vec::new(),
|
||||
source: source.clone(),
|
||||
swap: None,
|
||||
});
|
||||
if let Some(r) = root_rect {
|
||||
n.rect = r;
|
||||
@@ -199,17 +200,24 @@ pub fn sync_library_instance(tx: &mut Tx, inst: NodeId, ext: &Document) {
|
||||
|
||||
/// Bring one instance subtree in line with its main counterpart. `ext` is the document of
|
||||
/// the main's page when the link has a `source`; without it such an instance is left alone.
|
||||
/// A swapped nested instance syncs from the component it was swapped to (`ext` is then that
|
||||
/// component's page) and keeps its own place, like a root.
|
||||
pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&Document>) {
|
||||
let Some(n) = tx.doc().get(inst) else { return };
|
||||
let Some(link) = n.instance.clone() else {
|
||||
return;
|
||||
};
|
||||
let ext = ext.filter(|_| link.source.is_some());
|
||||
let main = match ext {
|
||||
None => tx.doc().get(link.main),
|
||||
Some(d) => d.get(link.main),
|
||||
let (main_id, source) = match &link.swap {
|
||||
Some(s) => (s.main, s.source.clone()),
|
||||
None => (link.main, link.source.clone()),
|
||||
};
|
||||
if link.source.is_some() && ext.is_none() {
|
||||
let is_root = is_root || link.swap.is_some();
|
||||
let ext = ext.filter(|_| source.is_some());
|
||||
let main = match ext {
|
||||
None => tx.doc().get(main_id),
|
||||
Some(d) => d.get(main_id),
|
||||
};
|
||||
if source.is_some() && ext.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(main) = main.cloned() else {
|
||||
@@ -236,23 +244,28 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
|
||||
.doc()
|
||||
.get(*k)
|
||||
.and_then(|c| c.instance.as_ref())
|
||||
.is_none_or(|l| !main.children.contains(&l.main) || l.source != link.source);
|
||||
.is_none_or(|l| !main.children.contains(&l.main) || l.source != source);
|
||||
if stale {
|
||||
tx.remove(*k);
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
}
|
||||
}
|
||||
for (i, mc) in main.children.iter().enumerate() {
|
||||
match by_main.iter().find(|(m, s, _)| m == mc && *s == link.source) {
|
||||
match by_main.iter().find(|(m, s, _)| m == mc && *s == source) {
|
||||
Some((_, _, k)) => {
|
||||
let k = *k;
|
||||
if tx.doc().children(Some(inst)).iter().position(|x| *x == k) != Some(i) {
|
||||
tx.move_to_index(k, i);
|
||||
}
|
||||
sync_node(tx, k, false, ext);
|
||||
// A kid swapped to a component on another page syncs from there (cross-page flush).
|
||||
let kid_ext = match tx.doc().get(k).and_then(|c| c.instance.as_ref()).and_then(|l| l.swap.as_ref()) {
|
||||
Some(s) if s.source != source => None,
|
||||
_ => ext,
|
||||
};
|
||||
sync_node(tx, k, false, kid_ext);
|
||||
}
|
||||
None => {
|
||||
instantiate_from(tx, ext, link.source.clone(), *mc, Some(inst), Some(i), None);
|
||||
instantiate_from(tx, ext, source.clone(), *mc, Some(inst), Some(i), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,9 +329,10 @@ impl Editor {
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
n.instance
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.main == *c && l.source.is_none())
|
||||
n.instance.as_ref().is_some_and(|l| match &l.swap {
|
||||
Some(s) => s.main == *c && s.source.is_none(),
|
||||
None => l.main == *c && l.source.is_none(),
|
||||
})
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
@@ -592,7 +606,8 @@ impl Editor {
|
||||
|
||||
/// The page and node a link points at, when that node exists.
|
||||
fn link_main(&self, l: &InstanceLink) -> Option<(usize, &Node)> {
|
||||
let page = match &l.source {
|
||||
let (main, source) = l.target();
|
||||
let page = match source {
|
||||
None => self.edit_page_index(),
|
||||
Some(Source::Page(p)) => self.pages.iter().position(|s| s.id == *p)?,
|
||||
Some(Source::Library(_)) => return None,
|
||||
@@ -602,19 +617,19 @@ impl Editor {
|
||||
} else {
|
||||
&self.pages[page].stash.as_ref()?.doc
|
||||
};
|
||||
doc.get(l.main).map(|n| (page, n))
|
||||
doc.get(main).map(|n| (page, n))
|
||||
}
|
||||
|
||||
/// The main of `l` is on a page that is still being read, so it cannot be looked at yet.
|
||||
fn link_pending(&self, l: &InstanceLink) -> bool {
|
||||
matches!(&l.source, Some(Source::Page(p)) if self.pages.iter().any(|s| s.id == *p && s.pending))
|
||||
matches!(l.target().1, Some(Source::Page(p)) if self.pages.iter().any(|s| s.id == *p && s.pending))
|
||||
}
|
||||
|
||||
/// A clone of the document holding the main of instance node `id`, when it is another page.
|
||||
pub(super) fn source_doc(&self, id: NodeId) -> Option<Document> {
|
||||
let l = self.doc.get(id)?.instance.as_ref()?;
|
||||
let Source::Page(_) = l.source.as_ref()? else {
|
||||
return match l.source.as_ref()? {
|
||||
let Source::Page(_) = l.target().1.as_ref()? else {
|
||||
return match l.target().1.as_ref()? {
|
||||
Source::Library(id) => self.library_docs.get(id).cloned(),
|
||||
Source::Page(_) => None,
|
||||
};
|
||||
@@ -1012,6 +1027,28 @@ pub fn swap_instance_tx(
|
||||
true
|
||||
}
|
||||
|
||||
/// Swap a nested instance (one inside an instance) to another component: it keeps its place in
|
||||
/// the outer instance, so later syncs of the outer main keep the swap.
|
||||
pub fn swap_nested_tx(tx: &mut Tx, ext: Option<&Document>, source: Option<Source>, inst: NodeId, new_main: NodeId) -> bool {
|
||||
let src = ext.unwrap_or(tx.doc());
|
||||
if !src.get(new_main).is_some_and(|c| c.component) {
|
||||
return false;
|
||||
}
|
||||
let name = src.get(new_main).unwrap().name.clone();
|
||||
for k in tx.doc().children(Some(inst)).to_vec() {
|
||||
tx.remove(k);
|
||||
}
|
||||
let Some(n) = tx.node_mut(inst) else { return false };
|
||||
let Some(link) = n.instance.as_mut() else { return false };
|
||||
link.swap = Some(Swap { main: new_main, source });
|
||||
link.overrides.retain(|o| o == "name");
|
||||
if !link.overrides.iter().any(|o| o == "name") {
|
||||
n.name = name;
|
||||
}
|
||||
sync_node(tx, inst, true, ext);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Issues for PaperCraft itself, filed on its repository from inside the app: by the user (a form)
|
||||
//! or drafted by the chat agent when the app gets in its way (`report_issue`, sent only when the
|
||||
//! user presses Send). Same rules as the `papercraft-self-report` skill: kinds `bug`, `docs`,
|
||||
//! `feature` as a `[kind]` title prefix, and no new issue when an open or closed one already has
|
||||
//! a matching title.
|
||||
//!
|
||||
//! Transport: a token saved in Settings (Gitea or GitHub REST API); without one, the `tea` CLI
|
||||
//! with the user's own login for that server.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::process::Command;
|
||||
|
||||
pub const KINDS: [&str; 3] = ["bug", "docs", "feature"];
|
||||
pub const DEFAULT_REPO: &str = "https://git.marcospaulo.dev.br/masi/papercraft";
|
||||
const KEY_USER: &str = "issues-token";
|
||||
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
/// Titles shorter than this are not compared: too little to call two issues the same.
|
||||
const MIN_MATCH: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Filed {
|
||||
New { number: u64, url: String },
|
||||
/// An issue with a matching title exists already; nothing was filed.
|
||||
Duplicate { number: u64, url: String, title: String },
|
||||
}
|
||||
|
||||
/// `(scheme://host, owner, repo)` of a repository URL.
|
||||
pub fn repo_parts(url: &str) -> Option<(String, String, String)> {
|
||||
let u = reqwest::Url::parse(url.trim().trim_end_matches(".git")).ok()?;
|
||||
let mut seg = u.path_segments()?.filter(|s| !s.is_empty());
|
||||
let (owner, repo) = (seg.next()?.to_owned(), seg.next()?.to_owned());
|
||||
Some((format!("{}://{}", u.scheme(), u.host_str()?), owner, repo))
|
||||
}
|
||||
|
||||
pub fn token() -> Option<String> {
|
||||
keyring::Entry::new("PaperCraft", KEY_USER).ok()?.get_password().ok().filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn set_token(t: &str) -> Result<(), String> {
|
||||
let e = keyring::Entry::new("PaperCraft", KEY_USER).map_err(|e| e.to_string())?;
|
||||
if t.trim().is_empty() {
|
||||
return match e.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
}
|
||||
e.set_password(t.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Files the draft (or finds the issue it duplicates). Blocking: call from a worker thread.
|
||||
pub fn file(repo: &str, d: &Draft) -> Result<Filed, String> {
|
||||
let (host, owner, name) = repo_parts(repo).ok_or("the issue repository is not a repository URL (Settings, Updates)")?;
|
||||
if !KINDS.contains(&d.kind.as_str()) {
|
||||
return Err(format!("kind must be one of {}", KINDS.join(", ")));
|
||||
}
|
||||
if d.title.trim().is_empty() {
|
||||
return Err("an issue needs a title".into());
|
||||
}
|
||||
let title = format!("[{}] {}", d.kind, d.title.trim());
|
||||
let body = format!(
|
||||
"{}\n\n---\nFiled from PaperCraft {} ({}).",
|
||||
d.body.trim(),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS
|
||||
);
|
||||
let existing = match token() {
|
||||
Some(t) => rest_list(&host, &owner, &name, &t)?,
|
||||
None => tea_list(&host, &owner, &name)?,
|
||||
};
|
||||
if let Some((number, url, t)) = existing.into_iter().find(|(_, _, t)| same_issue(t, &title)) {
|
||||
return Ok(Filed::Duplicate { number, url, title: t });
|
||||
}
|
||||
let (number, url) = match token() {
|
||||
Some(t) => rest_create(&host, &owner, &name, &t, &title, &body)?,
|
||||
None => tea_create(&host, &owner, &name, &title, &body, &d.kind)?,
|
||||
};
|
||||
Ok(Filed::New { number, url })
|
||||
}
|
||||
|
||||
/// Two titles describe the same issue: one contains the other, ignoring case and the `[kind]`.
|
||||
fn same_issue(a: &str, b: &str) -> bool {
|
||||
let norm = |t: &str| {
|
||||
let t = t.trim().to_lowercase();
|
||||
t.strip_prefix('[').and_then(|r| r.split_once(']')).map_or(t.as_str(), |(_, rest)| rest).trim().to_owned()
|
||||
};
|
||||
let (a, b) = (norm(a), norm(b));
|
||||
a.len().min(b.len()) >= MIN_MATCH && (a.contains(&b) || b.contains(&a))
|
||||
}
|
||||
|
||||
fn github(host: &str) -> bool {
|
||||
host.ends_with("://github.com")
|
||||
}
|
||||
|
||||
fn api(host: &str, owner: &str, name: &str) -> String {
|
||||
if github(host) { format!("https://api.github.com/repos/{owner}/{name}/issues") } else { format!("{host}/api/v1/repos/{owner}/{name}/issues") }
|
||||
}
|
||||
|
||||
fn rest(host: &str, token: &str, req: reqwest::RequestBuilder) -> Result<Value, String> {
|
||||
let auth = if github(host) { format!("Bearer {token}") } else { format!("token {token}") };
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
|
||||
rt.block_on(async {
|
||||
let resp = req.header("authorization", auth).header("user-agent", "PaperCraft").header("accept", "application/json").timeout(TIMEOUT).send().await.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
let v: Value = resp.json().await.unwrap_or(Value::Null);
|
||||
if !status.is_success() {
|
||||
return Err(format!("the issue tracker answered {status}: {}", v["message"].as_str().unwrap_or("")));
|
||||
}
|
||||
Ok(v)
|
||||
})
|
||||
}
|
||||
|
||||
fn rest_list(host: &str, owner: &str, name: &str, token: &str) -> Result<Vec<(u64, String, String)>, String> {
|
||||
let c = reqwest::Client::new();
|
||||
let url = format!("{}?state=all&limit=100&per_page=100&type=issues", api(host, owner, name));
|
||||
let v = rest(host, token, c.get(url))?;
|
||||
Ok(v.as_array().into_iter().flatten().filter_map(|i| Some((i["number"].as_u64()?, i["html_url"].as_str()?.to_owned(), i["title"].as_str()?.to_owned()))).collect())
|
||||
}
|
||||
|
||||
fn rest_create(host: &str, owner: &str, name: &str, token: &str, title: &str, body: &str) -> Result<(u64, String), String> {
|
||||
let c = reqwest::Client::new();
|
||||
let v = rest(host, token, c.post(api(host, owner, name)).json(&json!({"title": title, "body": body})))?;
|
||||
Ok((v["number"].as_u64().ok_or("the tracker returned no issue number")?, v["html_url"].as_str().unwrap_or_default().to_owned()))
|
||||
}
|
||||
|
||||
/// The `tea` login for a server (its name in tea's config), so tea talks to the right one.
|
||||
fn tea_login(host: &str) -> Option<String> {
|
||||
// tea keeps its config in ~/.config/tea on every platform (and the OS config folder on some).
|
||||
let cfg = [dirs::home_dir().map(|h| h.join(".config")), dirs::config_dir()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|d| std::fs::read_to_string(d.join("tea").join("config.yml")).ok())?;
|
||||
let mut name = None;
|
||||
for line in cfg.lines().map(str::trim) {
|
||||
if let Some(n) = line.strip_prefix("- name:") {
|
||||
name = Some(n.trim().to_owned());
|
||||
} else if let Some(u) = line.strip_prefix("url:")
|
||||
&& u.trim().trim_end_matches('/') == host
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn tea(args: &[&str]) -> Result<String, String> {
|
||||
let bin = crate::chat::path::which("tea").ok_or("no issue token in Settings and no `tea` CLI: add a token in Settings, Updates, Issues")?;
|
||||
let out = Command::new(bin).args(args).env("PATH", crate::chat::path::resolved_path()).output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(format!("tea: {}", err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn tea_list(host: &str, owner: &str, name: &str) -> Result<Vec<(u64, String, String)>, String> {
|
||||
let login = tea_login(host).ok_or_else(|| format!("tea has no login for {host}: run `tea login add`, or add a token in Settings"))?;
|
||||
let repo = format!("{owner}/{name}");
|
||||
let out = tea(&["issues", "ls", "--login", &login, "--repo", &repo, "--state", "all", "--limit", "200", "--output", "json", "--fields", "index,title,url"])?;
|
||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("tea output: {e}"))?;
|
||||
Ok(v.as_array().into_iter().flatten().filter_map(|i| Some((i["index"].as_str()?.parse().ok()?, i["url"].as_str()?.to_owned(), i["title"].as_str()?.to_owned()))).collect())
|
||||
}
|
||||
|
||||
fn tea_create(host: &str, owner: &str, name: &str, title: &str, body: &str, kind: &str) -> Result<(u64, String), String> {
|
||||
let login = tea_login(host).ok_or_else(|| format!("tea has no login for {host}"))?;
|
||||
let repo = format!("{owner}/{name}");
|
||||
// A label only when the repository has it; the [kind] prefix carries it otherwise.
|
||||
let labels = tea(&["labels", "ls", "--login", &login, "--repo", &repo, "--output", "json"]).ok().filter(|l| l.contains(&format!("\"{kind}\"")));
|
||||
let mut args = vec!["issues", "create", "--login", &login, "--repo", &repo, "--title", title, "--description", body];
|
||||
if labels.is_some() {
|
||||
args.extend(["--labels", kind]);
|
||||
}
|
||||
tea(&args)?;
|
||||
// tea prints a table, not JSON: read the new issue back by its title.
|
||||
tea_list(host, owner, name)?
|
||||
.into_iter()
|
||||
.find(|(_, _, t)| t == title)
|
||||
.map(|(n, u, _)| (n, u))
|
||||
.ok_or_else(|| "tea created the issue but it was not found afterwards".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repository_urls_and_duplicates() {
|
||||
assert_eq!(repo_parts("https://git.marcospaulo.dev.br/masi/papercraft.git"), Some(("https://git.marcospaulo.dev.br".into(), "masi".into(), "papercraft".into())));
|
||||
assert_eq!(repo_parts("not a url"), None);
|
||||
assert!(same_issue("[bug] write_html: border-left becomes a full border", "[docs] Write_HTML: border-left becomes a full border on frames"));
|
||||
assert!(!same_issue("[bug] short", "[bug] short"), "too short to call the same");
|
||||
assert!(!same_issue("[bug] rename field cannot be left", "[feature] copy layers between pages"));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub mod editor;
|
||||
pub mod geom;
|
||||
pub mod history;
|
||||
pub mod icons;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod issues;
|
||||
pub mod ink;
|
||||
pub mod io;
|
||||
pub mod layout;
|
||||
@@ -33,6 +35,7 @@ pub mod sync;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub mod ui_kits;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod update;
|
||||
pub mod variables;
|
||||
|
||||
@@ -732,7 +732,7 @@ mod tests {
|
||||
start_head: Default::default(),
|
||||
end_head: Default::default(),
|
||||
};
|
||||
inner.instance = Some(InstanceLink { main: NodeId(1), overrides: vec![], source: None });
|
||||
inner.instance = Some(InstanceLink { main: NodeId(1), overrides: vec![], source: None, swap: None });
|
||||
root.children = vec![NodeId(2), NodeId(3)];
|
||||
inner.parent = Some(NodeId(1));
|
||||
line.parent = Some(NodeId(1));
|
||||
|
||||
@@ -397,9 +397,68 @@ fn instance_comp_attr(doc: &Document, n: &Node) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The code link of a node: its own (a main component) or its main component's (an instance).
|
||||
pub(crate) fn code_link<'a>(doc: &'a Document, n: &'a Node) -> Option<&'a crate::model::CodeLink> {
|
||||
n.code.as_ref().or_else(|| n.instance.as_ref().and_then(|l| doc.get(l.target().0)).and_then(|m| m.code.as_ref()))
|
||||
}
|
||||
|
||||
/// A code link template with `{Name}` replaced by the text of the layer named `Name` under `id`
|
||||
/// (the instance's own text, overrides included). An unknown name stays as written.
|
||||
fn fill_template(doc: &Document, id: NodeId, template: &str, escape: fn(&str) -> String) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let Some(j) = rest[i..].find('}').map(|j| i + j) else { break };
|
||||
let name = &rest[i + 1..j];
|
||||
let text = (!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || " _-".contains(c)))
|
||||
.then(|| doc.subtree(id).into_iter().filter_map(|d| doc.get(d)).find(|d| d.name == name).and_then(|d| d.kind.text().map(|t| t.to_string())))
|
||||
.flatten();
|
||||
out.push_str(&rest[..i]);
|
||||
match text {
|
||||
Some(t) => out.push_str(&escape(&t)),
|
||||
None => out.push_str(&rest[i..=j]),
|
||||
}
|
||||
rest = &rest[j + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// A linked component's JSX, filled in from the instance (None when it has no React link).
|
||||
pub(crate) fn linked_react(doc: &Document, id: NodeId) -> Option<String> {
|
||||
let n = doc.get(id)?;
|
||||
code_link(doc, n).filter(|l| !l.react.is_empty()).map(|l| fill_template(doc, id, &l.react, jsx_text))
|
||||
}
|
||||
|
||||
/// The distinct import lines of the linked components under `root`, in order of first use.
|
||||
pub(crate) fn code_imports(doc: &Document, root: NodeId) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for id in doc.subtree(root) {
|
||||
if let Some(link) = doc.get(id).and_then(|n| code_link(doc, n))
|
||||
&& !link.import.is_empty()
|
||||
&& !out.contains(&link.import)
|
||||
{
|
||||
out.push(link.import.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Each line of a (possibly multi-line) snippet, indented.
|
||||
pub(crate) fn push_indented(out: &mut String, ind: &str, snippet: &str) {
|
||||
for line in snippet.lines() {
|
||||
out.push_str(&format!("{ind}{line}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &mut String) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
let ind = " ".repeat(depth);
|
||||
// A linked component is written as its library usage, not rebuilt from its layers.
|
||||
if let Some(link) = code_link(doc, n).filter(|l| !l.react.is_empty()) {
|
||||
push_indented(out, &ind, &fill_template(doc, id, &link.react, jsx_text));
|
||||
return;
|
||||
}
|
||||
let cls = tw_classes(doc, n, is_root);
|
||||
let name = n.name.replace('"', "'");
|
||||
let comp_attr = instance_comp_attr(doc, n);
|
||||
@@ -467,6 +526,12 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
|
||||
/// Serialize `root` and its descendants into clean React JSX with Tailwind CSS utility classes.
|
||||
pub fn to_tailwind_jsx(doc: &Document, root: NodeId) -> String {
|
||||
let mut out = String::new();
|
||||
for import in code_imports(doc, root) {
|
||||
out.push_str(&format!("{import}\n"));
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
tailwind_node(doc, root, 0, true, &mut out);
|
||||
out
|
||||
}
|
||||
@@ -835,10 +900,14 @@ pub fn to_react_tsx(doc: &Document, root: NodeId) -> String {
|
||||
.get(root)
|
||||
.map(|n| pascal_ident(&n.name))
|
||||
.unwrap_or_else(|| "Component".into());
|
||||
let jsx = to_tailwind_jsx(doc, root);
|
||||
for import in code_imports(doc, root) {
|
||||
out.push_str(&format!("{import}\n"));
|
||||
}
|
||||
let mut jsx = String::new();
|
||||
tailwind_node(doc, root, 0, true, &mut jsx);
|
||||
let body: String = jsx.lines().map(|line| format!(" {line}\n")).collect();
|
||||
out.push_str(&format!(
|
||||
"export function {comp_name}() {{\n return (\n{body} );\n}}\n"
|
||||
"\nexport function {comp_name}() {{\n return (\n{body} );\n}}\n"
|
||||
));
|
||||
out
|
||||
}
|
||||
@@ -1007,6 +1076,11 @@ fn egui_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &mut
|
||||
return;
|
||||
}
|
||||
let ind = " ".repeat(depth);
|
||||
// A linked component is the widget call it stands for.
|
||||
if let Some(link) = code_link(doc, n).filter(|l| !l.egui.is_empty()) {
|
||||
push_indented(out, &ind, &fill_template(doc, id, &link.egui, |t| format!("{t:?}")));
|
||||
return;
|
||||
}
|
||||
if let Some((set, icon)) = icon_of(doc, n) {
|
||||
let (hex, var) = icon_tint(doc, n);
|
||||
let tint = hex
|
||||
@@ -1308,10 +1382,21 @@ fn build_spec_tree(
|
||||
if !is_root && in_flow {
|
||||
*flow_count += 1;
|
||||
}
|
||||
// A linked component is one library call: no rules for the layers that draw it (the
|
||||
// "MUST use the <kit> component" rule covers it).
|
||||
if !is_root && let Some(link) = code_link(doc, n) {
|
||||
return Some(json!({
|
||||
"id": n.id.0, "name": n.name, "kind": "library",
|
||||
"library": {"kit": link.kit, "import": link.import, "react": link.react, "egui": link.egui,
|
||||
"usage": fill_template(doc, id, &link.react, jsx_text), "eguiUsage": fill_template(doc, id, &link.egui, |t| format!("{t:?}"))},
|
||||
"bounds": {"width": n.rect.w, "height": n.rect.h},
|
||||
"sizing": {"horizontal": "fixed", "vertical": "fixed", "inFlow": in_flow},
|
||||
}));
|
||||
}
|
||||
|
||||
let mut instance_of: Option<Value> = None;
|
||||
if let Some(link) = &n.instance
|
||||
&& let Some(main) = doc.get(link.main)
|
||||
&& let Some(main) = doc.get(link.target().0)
|
||||
&& main.component
|
||||
{
|
||||
instance_of = Some(
|
||||
@@ -1640,6 +1725,19 @@ pub(crate) fn to_ai_spec_with_categories(
|
||||
"MUST NOT use `position: absolute` (or Tailwind `absolute`) on in-flow children ({flow_count} flow nodes under root)"
|
||||
),
|
||||
);
|
||||
let library = library_components(doc, root);
|
||||
for l in &library {
|
||||
let usage = if matches!(target.trim().to_ascii_lowercase().as_str(), "egui" | "rust") { &l["egui"] } else { &l["react"] };
|
||||
if let Some(u) = usage.as_str().filter(|u| !u.is_empty()) {
|
||||
rules.push(format!(
|
||||
"MUST use the {} component for `{}` ({} uses: `{}`), not rebuild it from its layers",
|
||||
l["kit"].as_str().unwrap_or(""),
|
||||
l["name"].as_str().unwrap_or(""),
|
||||
l["usedBy"].as_array().map_or(0, Vec::len),
|
||||
u.lines().next().unwrap_or(u)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let colors: Map<String, Value> = doc
|
||||
.variables
|
||||
@@ -1658,7 +1756,7 @@ pub(crate) fn to_ai_spec_with_categories(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(main) = inst.instance.as_ref().map(|l| l.main) else {
|
||||
let Some(main) = inst.instance.as_ref().map(|l| l.target().0) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(&main) {
|
||||
@@ -1689,6 +1787,7 @@ pub(crate) fn to_ai_spec_with_categories(
|
||||
},
|
||||
"components": components,
|
||||
"componentDefinitions": definitions,
|
||||
"libraryComponents": library,
|
||||
"layoutTree": layout_tree,
|
||||
"enforcementRules": rules,
|
||||
"annotations": annotation_comments(doc, root, categories).lines().collect::<Vec<_>>(),
|
||||
@@ -1696,6 +1795,27 @@ pub(crate) fn to_ai_spec_with_categories(
|
||||
}))
|
||||
}
|
||||
|
||||
/// The linked components used under `root`: one entry per main component, with the layers
|
||||
/// that use it. Nested layers of a linked component are not looked into.
|
||||
fn library_components(doc: &Document, root: NodeId) -> Vec<Value> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut stack = vec![root];
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(n) = doc.get(id) else { continue };
|
||||
let Some(link) = code_link(doc, n) else {
|
||||
stack.extend(n.children.iter().rev());
|
||||
continue;
|
||||
};
|
||||
let main = n.instance.as_ref().map_or(id, |l| l.target().0);
|
||||
let name = doc.get(main).map_or_else(|| n.name.clone(), |m| m.name.clone());
|
||||
match out.iter_mut().find(|e| e["mainId"] == json!(main.0)) {
|
||||
Some(e) => e["usedBy"].as_array_mut().unwrap().push(json!(id.0)),
|
||||
None => out.push(json!({"name": name, "mainId": main.0, "kit": link.kit, "import": link.import, "react": link.react, "egui": link.egui, "usedBy": [id.0]})),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
|
||||
let ind = " ".repeat(depth);
|
||||
let id = node["id"].as_u64().unwrap_or(0);
|
||||
@@ -1744,6 +1864,10 @@ fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
|
||||
details.push(format!("line-height={}px", px_num(lh)));
|
||||
}
|
||||
}
|
||||
if let Some(lib) = node.get("library") {
|
||||
let usage = lib["usage"].as_str().filter(|u| !u.is_empty()).or(lib["eguiUsage"].as_str()).unwrap_or("");
|
||||
details.push(format!("use `{}`", usage.lines().next().unwrap_or(usage)));
|
||||
}
|
||||
if let Some(ic) = node.get("icon") {
|
||||
details.push(format!(
|
||||
"icon={}:{} tint={}",
|
||||
@@ -1768,6 +1892,10 @@ fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
fn lang_is_egui(target: &str) -> bool {
|
||||
matches!(target.trim().to_ascii_lowercase().as_str(), "egui" | "rust")
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
to_ai_spec_markdown_with_categories(doc, root, target, &crate::model::annotation::default_categories())
|
||||
@@ -1808,7 +1936,27 @@ pub(crate) fn to_ai_spec_markdown_with_categories(
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
md.push_str("## 3. Structured Layout Hierarchy\n\n");
|
||||
// Sections are numbered as they appear: the library one only shows when there is one.
|
||||
let mut section = 2;
|
||||
let mut next = || {
|
||||
section += 1;
|
||||
section
|
||||
};
|
||||
if let Some(lib) = spec["libraryComponents"].as_array().filter(|l| !l.is_empty()) {
|
||||
let egui = lang_is_egui(target);
|
||||
md.push_str(&format!("## {}. Library Components\n\nUse these components; the layers under them are only how they look.\n\n", next()));
|
||||
for l in lib {
|
||||
let usage = if egui { l["egui"].as_str() } else { l["react"].as_str() }.unwrap_or("");
|
||||
md.push_str(&format!("- **`{}`** ({}), used {}×", l["name"].as_str().unwrap_or(""), l["kit"].as_str().unwrap_or(""), l["usedBy"].as_array().map_or(0, Vec::len)));
|
||||
if !egui && let Some(i) = l["import"].as_str().filter(|i| !i.is_empty()) {
|
||||
md.push_str(&format!(": `{i}`"));
|
||||
}
|
||||
md.push_str(&format!("\n ```{}\n {}\n ```\n", if egui { "rust" } else { "tsx" }, usage.replace('\n', "\n ")));
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
md.push_str(&format!("## {}. Structured Layout Hierarchy\n\n", next()));
|
||||
format_hierarchy_md(&spec["layoutTree"], 0, &mut md);
|
||||
md.push('\n');
|
||||
|
||||
@@ -1821,7 +1969,8 @@ pub(crate) fn to_ai_spec_markdown_with_categories(
|
||||
};
|
||||
let ref_code = spec["referenceCode"].as_str().unwrap_or("");
|
||||
md.push_str(&format!(
|
||||
"## 4. Reference Implementation ({target})\n\n```{lang_tag}\n{}\n```\n",
|
||||
"## {}. Reference Implementation ({target})\n\n```{lang_tag}\n{}\n```\n",
|
||||
next(),
|
||||
ref_code.trim_end()
|
||||
));
|
||||
|
||||
@@ -1860,6 +2009,7 @@ mod tests {
|
||||
main: comp_id,
|
||||
overrides: Vec::new(),
|
||||
source: None,
|
||||
swap: None,
|
||||
});
|
||||
frame.layout = Some(AutoLayout {
|
||||
direction: Direction::Vertical,
|
||||
@@ -2046,4 +2196,55 @@ mod tests {
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_components_export_as_library_code() {
|
||||
let mut doc = Document::default();
|
||||
let frame = |doc: &mut Document, name: &str| {
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 90.0, 36.0));
|
||||
n.name = name.into();
|
||||
doc.nodes.insert(id, n);
|
||||
id
|
||||
};
|
||||
let label = |doc: &mut Document, parent: NodeId, text: &str| {
|
||||
let id = doc.alloc_id();
|
||||
let mut t = Node::new(id, NodeKind::Text { text: text.into(), font_size: 14.0, auto_width: true, align: TextAlign::Left }, Rect::new(0.0, 0.0, 50.0, 20.0));
|
||||
t.name = "Label".into();
|
||||
t.parent = Some(parent);
|
||||
doc.nodes.insert(id, t);
|
||||
doc.nodes.get_mut(&parent).unwrap().children.push(id);
|
||||
};
|
||||
let main = frame(&mut doc, "Button / Outline");
|
||||
label(&mut doc, main, "Button");
|
||||
{
|
||||
let m = doc.nodes.get_mut(&main).unwrap();
|
||||
m.component = true;
|
||||
m.code = Some(crate::model::CodeLink {
|
||||
kit: "shadcn".into(),
|
||||
import: r#"import { Button } from "@/components/ui/button""#.into(),
|
||||
react: r#"<Button variant="outline">{Label}</Button>"#.into(),
|
||||
egui: "ui.button({Label});".into(),
|
||||
});
|
||||
}
|
||||
let screen = frame(&mut doc, "Screen");
|
||||
doc.root.push(screen);
|
||||
for text in ["Save", "Cancel"] {
|
||||
let inst = frame(&mut doc, "Button / Outline");
|
||||
label(&mut doc, inst, text);
|
||||
let n = doc.nodes.get_mut(&inst).unwrap();
|
||||
n.instance = Some(InstanceLink { main, overrides: Vec::new(), source: None, swap: None });
|
||||
n.parent = Some(screen);
|
||||
doc.nodes.get_mut(&screen).unwrap().children.push(inst);
|
||||
}
|
||||
let tsx = to_react_tsx(&doc, screen);
|
||||
assert_eq!(tsx.matches("import { Button }").count(), 1, "{tsx}");
|
||||
assert!(tsx.contains(r#"<Button variant="outline">Save</Button>"#) && tsx.contains(r#"<Button variant="outline">Cancel</Button>"#), "{tsx}");
|
||||
assert!(!tsx.contains("data-name=\"Label\""), "the layers under a linked component are not rebuilt: {tsx}");
|
||||
let rs = to_egui(&doc, screen);
|
||||
assert!(rs.contains(r#"ui.button("Save");"#), "{rs}");
|
||||
let spec = to_ai_spec(&doc, screen, "react").unwrap();
|
||||
assert_eq!(spec["libraryComponents"][0]["usedBy"].as_array().unwrap().len(), 2);
|
||||
assert!(spec["enforcementRules"].as_array().unwrap().iter().any(|r| r.as_str().unwrap().contains("MUST use the shadcn component")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1815,7 +1815,14 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
if let Some(c) = n.style.fill() {
|
||||
// A colour bound to a variable reads as that variable, like a background does.
|
||||
let var = n.style.fills.iter().find_map(|p| match p {
|
||||
Paint::Solid { var: Some(v), .. } => Some(v.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(v) = var.or_else(|| n.bindings.get("text-color").cloned()) {
|
||||
put("color", format!("var(--{})", v.trim_start_matches("--")));
|
||||
} else if let Some(c) = n.style.fill() {
|
||||
put("color", color_css(c));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! `delegate`: the chat agent hands review, reading or a well-specified build to a helper agent
|
||||
//! on a cheaper model (pi, opencode, an Ollama-backed agent: any CLI that takes a prompt and has
|
||||
//! a shell). The helper drives PaperCraft through `papercraft-cli`, which reaches this app (it
|
||||
//! inherits `PAPERCRAFT_MCP_PORT`), and only its final answer comes back into the main context.
|
||||
|
||||
use super::{Out, ToolResult};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const TOOL: &str = "delegate";
|
||||
pub const ROLES: [&str; 3] = ["qa", "read", "build"];
|
||||
/// The user's pi with MiniMax M3.1 Flash: fast, cheap, sees images.
|
||||
pub const PI_COMMAND: &str = "pi -p --no-session -nc -ns --model minimax/MiniMax-M3.1-Flash {prompt}";
|
||||
/// opencode with its default model; add `-m provider/model` (an `ollama/...` one runs locally).
|
||||
pub const OPENCODE_COMMAND: &str = "opencode run {prompt}";
|
||||
const TIMEOUT: Duration = Duration::from_secs(900);
|
||||
const POLL: Duration = Duration::from_millis(250);
|
||||
/// The tail of the helper's output that comes back: its answer, not its tool log.
|
||||
const MAX_ANSWER: usize = 6000;
|
||||
|
||||
/// Helpers running for a chat, each with its stop flag; the chat's Stop button sets them.
|
||||
static RUNNING: Mutex<Vec<(String, Arc<AtomicBool>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Stop the helpers the chat `chat` started (its Stop button).
|
||||
pub fn stop(chat: &str) {
|
||||
for (c, flag) in RUNNING.lock().unwrap().iter() {
|
||||
if c == chat {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the helper `command` (`{prompt}` is passed as `$PAPERCRAFT_PROMPT`) for one task.
|
||||
/// `chat`: the chat that asked, so its Stop button ends the helper too.
|
||||
pub fn run(command: &str, chat: Option<&str>, args: &serde_json::Value) -> ToolResult {
|
||||
if command.trim().is_empty() {
|
||||
return Err("No helper agent is set up. The user can choose one in Settings, Chat, Helper; do the work yourself.".into());
|
||||
}
|
||||
let role = args["role"].as_str().filter(|r| ROLES.contains(r)).ok_or("role must be qa, read or build")?;
|
||||
let task = args["task"].as_str().map(str::trim).filter(|t| !t.is_empty()).ok_or("give the task with everything the helper needs")?;
|
||||
let cli = crate::chat::path::which("papercraft-cli").ok_or("papercraft-cli is not installed, so a helper cannot reach PaperCraft")?;
|
||||
let prompt = format!("{}\n\n{}\n\nTask:\n{task}", preamble(&cli.display().to_string()), role_rules(role));
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.args(["/C", &command.replace("{prompt}", "%PAPERCRAFT_PROMPT%")]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.args(["-c", &command.replace("{prompt}", "\"$PAPERCRAFT_PROMPT\"")]);
|
||||
c
|
||||
};
|
||||
// Its own process group, so stopping it also ends the agent the shell started.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
|
||||
let mut child = cmd
|
||||
.env("PAPERCRAFT_PROMPT", &prompt)
|
||||
// An app opened from Finder has a bare PATH: give the helper the user's shell PATH.
|
||||
.env("PATH", crate::chat::path::resolved_path())
|
||||
.current_dir(std::env::temp_dir())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("could not start the helper: {e}"))?;
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let key = chat.unwrap_or_default().to_owned();
|
||||
RUNNING.lock().unwrap().push((key, flag.clone()));
|
||||
let started = Instant::now();
|
||||
let ended = loop {
|
||||
match child.try_wait() {
|
||||
Err(e) => break Err(e.to_string()),
|
||||
Ok(Some(_)) => break Ok(()),
|
||||
Ok(None) => {}
|
||||
}
|
||||
let late = started.elapsed() > TIMEOUT;
|
||||
if late || flag.load(Ordering::Relaxed) {
|
||||
kill_group(&mut child);
|
||||
break Err(if late { format!("the helper took longer than {} minutes", TIMEOUT.as_secs() / 60) } else { "stopped by the user".into() });
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
};
|
||||
RUNNING.lock().unwrap().retain(|(_, f)| !Arc::ptr_eq(f, &flag));
|
||||
ended?;
|
||||
let done = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
let out = String::from_utf8_lossy(&done.stdout);
|
||||
if !done.status.success() || out.trim().is_empty() {
|
||||
let err = String::from_utf8_lossy(&done.stderr);
|
||||
return Err(format!("the helper failed: {}", err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no output")));
|
||||
}
|
||||
Ok(vec![Out::Text(tail(out.trim(), MAX_ANSWER).to_owned())])
|
||||
}
|
||||
|
||||
/// End the helper and everything it started.
|
||||
fn kill_group(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
let _ = Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn preamble(cli: &str) -> String {
|
||||
format!(
|
||||
"You are a helper agent for a design agent working in PaperCraft, a design app. You do not see its \
|
||||
chat: everything you need is below. Drive PaperCraft only with its CLI from your shell: \
|
||||
`{cli} call <tool> '<json args>'` (tool names: `{cli} list`; a tool's arguments: `{cli} schema <tool>`). \
|
||||
For an image result add `-o <file>.png` and look at the file if you can view images. Work only on \
|
||||
what the task names. End with only your answer in plain text, no preamble."
|
||||
)
|
||||
}
|
||||
|
||||
fn role_rules(role: &str) -> &'static str {
|
||||
match role {
|
||||
"qa" => "Role: QA reviewer. Do not edit. Run lint_design without fix, then check each frame the task names \
|
||||
(get_screenshot if you can view images, else get_tree_summary and get_computed_styles): overflow and \
|
||||
clipped text, alignment and spacing, literal or off-palette colors, contrast, target sizes, placeholder \
|
||||
content, look-alikes that should be instances. Reply with at most 12 findings, most severe first, one \
|
||||
line each: node id, problem, fix. Or 'No findings'.",
|
||||
"read" => "Role: reader. Do not edit. Read what the task names (pages, frames, specs, files, URLs) and return only \
|
||||
the key points it asks for, at most 25 lines, with node ids where they matter.",
|
||||
_ => "Role: builder. Make exactly the edits the task specifies with the variables, tokens and components it names \
|
||||
(write_html with var(--name) and <x-paper-instance>). Do not redesign or add anything else. Then run \
|
||||
lint_design on what you changed. Reply in at most 10 lines: what you made (node ids) and lint warnings left.",
|
||||
}
|
||||
}
|
||||
|
||||
/// The last `max` bytes of `s`, cut at a character boundary.
|
||||
fn tail(s: &str, max: usize) -> &str {
|
||||
let mut i = s.len().saturating_sub(max);
|
||||
while !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
&s[i..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_task_reaches_the_helper_as_data_and_only_its_answer_returns() {
|
||||
if crate::chat::path::which("papercraft-cli").is_none() {
|
||||
return;
|
||||
}
|
||||
// The task tries to run a command; it must arrive as plain text.
|
||||
let task = "check Home\"; touch pwned; echo \"";
|
||||
let out = run("printf '%s' {prompt} | tail -c 40", None, &json!({"role": "qa", "task": task})).unwrap();
|
||||
let Out::Text(t) = &out[0] else { panic!() };
|
||||
assert!(t.ends_with(task), "{t}");
|
||||
assert!(!std::env::temp_dir().join("pwned").exists());
|
||||
assert!(run("", None, &json!({"role": "qa", "task": "x"})).unwrap_err().contains("Settings"));
|
||||
assert!(run("echo", None, &json!({"role": "design", "task": "x"})).is_err());
|
||||
assert!(run("exit 3", None, &json!({"role": "read", "task": "x"})).is_err());
|
||||
assert_eq!(tail("héllo", 4), "llo");
|
||||
// Stop ends a running helper, and the process it started, at once.
|
||||
let t = std::thread::spawn(|| run("sleep 30", Some("chat-stop-test"), &json!({"role": "qa", "task": "x"})));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let begun = Instant::now();
|
||||
stop("chat-stop-test");
|
||||
assert_eq!(t.join().unwrap().unwrap_err(), "stopped by the user");
|
||||
assert!(begun.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -742,6 +742,7 @@ mod tests {
|
||||
main: comp_id,
|
||||
overrides: Vec::new(),
|
||||
source: None,
|
||||
swap: None,
|
||||
});
|
||||
|
||||
let drift_code = r#"
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::history::Tx;
|
||||
use crate::model::annotation::AnnotationCategory;
|
||||
use crate::model::{
|
||||
AutoLayout, Binding, Color, Direction, Document, Head, ImageMode, Node, NodeId, NodeKind,
|
||||
Paint, PathPoint, Route, Sizing, Source, SubPath, TextAlign,
|
||||
InstanceLink, Paint, PathPoint, Route, Sizing, Source, SubPath, Swap, TextAlign,
|
||||
};
|
||||
use crate::project::{Project, Store};
|
||||
use crate::variables::{self, DesignSystem, Kind, Value as DsVal};
|
||||
@@ -38,6 +38,8 @@ pub trait Host {
|
||||
fn open_project(&mut self, dir: &Path) -> Result<(), String>;
|
||||
/// Save the open project now.
|
||||
fn save(&mut self) -> Result<(), String>;
|
||||
/// Rename the open project (its manifest and folder); returns the new folder.
|
||||
fn rename_project(&mut self, name: &str) -> Result<PathBuf, String>;
|
||||
/// An offscreen renderer for screenshots and PNG export.
|
||||
fn gpu(&mut self) -> Option<Headless>;
|
||||
/// The host cannot render synchronously but will render a [`ShotRequest`] itself and answer
|
||||
@@ -101,6 +103,9 @@ impl Host for SessionHost {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rename_project(&mut self, name: &str) -> Result<PathBuf, String> {
|
||||
self.session.rename_open(&mut self.ed, name)
|
||||
}
|
||||
fn gpu(&mut self) -> Option<Headless> {
|
||||
if !self.gpu_tried {
|
||||
self.gpu_tried = true;
|
||||
@@ -262,6 +267,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"list_resources" => list_resources(host, &a),
|
||||
"open_file" => open_file(host, &a),
|
||||
"create_file" => create_file(host, &a),
|
||||
"rename_file" => rename_file(host, &a),
|
||||
"get_font_family_info" => font_info(&a),
|
||||
"get_guide" => guide(&a),
|
||||
"publish_library" | "list_libraries" | "enable_library" | "update_library"
|
||||
@@ -306,6 +312,9 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"get_fill_image" => fill_image(ed, &a),
|
||||
"zoom_image" => zoom_image(host, &a),
|
||||
"ask_user" => ask_user(&a),
|
||||
"report_issue" => report_issue(&a),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"design_rules" => design_rules(&a),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"fetch_reference_images" => super::refs::fetch(host.project().map(|p| p.dir.clone()).as_deref(), None, &Value::Object(a.0.clone())),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -313,6 +322,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
let provider = crate::ui::settings::ChatPrefs::load().image_provider();
|
||||
super::imagegen::generate(host.project().map(|p| p.dir.clone()).as_deref(), None, &provider, &Value::Object(a.0.clone()))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"delegate" => super::delegate::run(&crate::ui::settings::ChatPrefs::load().helper_command, None, &Value::Object(a.0.clone())),
|
||||
"find_nodes" => find_nodes(ed, &a),
|
||||
"export" => export(host, &a),
|
||||
"export_combined_pdf" => export_pdf(host, &a),
|
||||
@@ -343,6 +354,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"diff_frames" => diff_frames(ed, &a),
|
||||
"search_icons" => search_icons(&a),
|
||||
"rename_nodes" => rename_nodes(ed, &a),
|
||||
"set_code_link" => set_code_link(ed, &a),
|
||||
"apply_ui_kit" => apply_ui_kit(host, &a),
|
||||
"delete_nodes" => delete_nodes(ed, &a),
|
||||
"duplicate_nodes" => duplicate_nodes(ed, &a),
|
||||
"move_nodes" => move_nodes(ed, &a),
|
||||
@@ -477,6 +490,25 @@ fn create_file(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Rename a project: the open one by default (saved first), or any project by `fileId`.
|
||||
fn rename_file(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let name = a.req_str("name")?.trim();
|
||||
if name.is_empty() {
|
||||
return Err("name must not be empty".into());
|
||||
}
|
||||
let open = host.project().map(|p| p.dir.clone());
|
||||
let dir = match a.str("fileId").filter(|f| !f.is_empty()) {
|
||||
Some(f) => find_project(host.store(), f)?,
|
||||
None => open.clone().ok_or("no project is open: pass fileId")?,
|
||||
};
|
||||
let path = if open.is_some_and(|o| same_dir(&o, &dir)) {
|
||||
host.rename_project(name)?
|
||||
} else {
|
||||
host.store().rename(&dir, name).map_err(|e| e.to_string())?
|
||||
};
|
||||
text(json!({"name": name, "path": path}))
|
||||
}
|
||||
|
||||
fn create_page(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
// The new page copies the active page's modes and cache (`insert_empty_page`).
|
||||
let i = ed.add_page_in_background(a.str("name"));
|
||||
@@ -1245,9 +1277,38 @@ fn ask_user(a: &Args) -> ToolResult {
|
||||
text(json!({"shown": true, "next": "End your turn now. The answers arrive as the user's next message."}))
|
||||
}
|
||||
|
||||
const ASK_MAX_QUESTIONS: usize = 5;
|
||||
const ASK_MAX_QUESTIONS: usize = 7;
|
||||
|
||||
/// An issue draft: the chat shows it with Send and Discard; nothing is filed from here.
|
||||
fn report_issue(a: &Args) -> ToolResult {
|
||||
let kind = a.req_str("kind")?;
|
||||
if !["bug", "docs", "feature"].contains(&kind) {
|
||||
return Err("kind must be bug, docs or feature".into());
|
||||
}
|
||||
if a.req_str("title")?.is_empty() || a.req_str("body")?.is_empty() {
|
||||
return Err("an issue needs a title and a body".into());
|
||||
}
|
||||
text(json!({"draft": true, "next": "The user sees the draft and sends or discards it. Do not repeat it in text; go on with the task."}))
|
||||
}
|
||||
const ASK_MAX_OPTIONS: usize = 6;
|
||||
|
||||
/// design-playbook rules through its CLI: `playbook search` or `playbook rule`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn design_rules(a: &Args) -> ToolResult {
|
||||
let bin = crate::chat::path::which("playbook").ok_or("the design-playbook CLI (`playbook`) is not installed")?;
|
||||
let args = match (a.str("rule"), a.str("query")) {
|
||||
(Some(r), _) if !r.trim().is_empty() => vec!["rule", r.trim()],
|
||||
(_, Some(q)) if !q.trim().is_empty() => vec!["search", "-n", "5", q.trim()],
|
||||
_ => return Err("give query or rule".into()),
|
||||
};
|
||||
let out = std::process::Command::new(bin).args(&args).output().map_err(|e| e.to_string())?;
|
||||
let found = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
if found.is_empty() {
|
||||
return Err(format!("no playbook match: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
text(json!({"rules": found}))
|
||||
}
|
||||
|
||||
/// A region of a chat attachment at full resolution: the `.full` original kept next to the sent
|
||||
/// image when it was scaled down, else the image itself. Only files in the project's chat folder.
|
||||
fn zoom_image(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
@@ -1828,10 +1889,12 @@ fn annotation_ref(ed: &Editor, value: &Value, page: usize) -> Result<u64, String
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
let (root, num) = numbers[&n.id];
|
||||
// get_annotations lists notes without a frame as "Detached #n".
|
||||
num == number
|
||||
&& root
|
||||
.and_then(|id| doc.get(id))
|
||||
.is_some_and(|node| node.name.eq_ignore_ascii_case(frame))
|
||||
&& match root.and_then(|id| doc.get(id)) {
|
||||
Some(node) => node.name.eq_ignore_ascii_case(frame),
|
||||
None => frame.eq_ignore_ascii_case("Detached"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
@@ -1907,6 +1970,9 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
match a.str("format") {
|
||||
None | Some("") | Some("inline") | Some("inline-styles") => {
|
||||
let mut out = super::codegen::annotation_comments(doc, id, &ed.design.categories());
|
||||
for import in super::codegen::code_imports(doc, id) {
|
||||
out.push_str(&format!("{import}\n"));
|
||||
}
|
||||
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
|
||||
Ok(vec![Out::Text(out)])
|
||||
}
|
||||
@@ -1978,6 +2044,11 @@ fn jsx_node(
|
||||
) {
|
||||
let n = doc.get(id).unwrap();
|
||||
let ind = " ".repeat(depth);
|
||||
// A linked component is written as its library usage.
|
||||
if let Some(jsx) = super::codegen::linked_react(doc, id) {
|
||||
super::codegen::push_indented(out, &ind, &jsx);
|
||||
return;
|
||||
}
|
||||
let mut st = css::computed(n, parent_layout(doc, n).as_ref(), n.parent.is_none());
|
||||
if root {
|
||||
for k in ["position", "left", "top"] {
|
||||
@@ -3457,7 +3528,8 @@ impl Builder<'_, '_> {
|
||||
let w = src_doc
|
||||
.world_rect(src)
|
||||
.unwrap_or_else(|| src_doc.get(src).unwrap().rect);
|
||||
clone_subtree_from_doc(self.tx, src_doc, src, parent.id, *index, -w.min()).0
|
||||
let pid = self.all_pages.iter().find(|(_, _, d)| std::ptr::eq(d, src_doc)).map(|(pid, _, _)| pid.as_str());
|
||||
clone_subtree_from_doc(self.tx, src_doc, src, parent.id, *index, -w.min(), pid.map(|p| (p, self.page_id.as_str())), false).0
|
||||
} else {
|
||||
self.warnings
|
||||
.push(format!("x-paper-clone: node `{key}` not found"));
|
||||
@@ -3482,6 +3554,9 @@ impl Builder<'_, '_> {
|
||||
|
||||
/// Deep-copy a subtree from `src_doc` into `tx` under `parent` (keeping its world position plus
|
||||
/// `offset`). Returns the new root and the (old → new) id pairs.
|
||||
/// `pages`: the (source, destination) page ids, so links to mains left on the source page and
|
||||
/// links that become same-page are kept. `keep_keys`: a move, so mains keep their component keys.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn clone_subtree_from_doc(
|
||||
tx: &mut Tx,
|
||||
src_doc: &Document,
|
||||
@@ -3489,6 +3564,8 @@ fn clone_subtree_from_doc(
|
||||
parent: Option<NodeId>,
|
||||
index: Option<usize>,
|
||||
offset: V2,
|
||||
pages: Option<(&str, &str)>,
|
||||
keep_keys: bool,
|
||||
) -> (NodeId, Vec<(NodeId, NodeId)>) {
|
||||
let ids = src_doc.subtree(src);
|
||||
let nodes: Vec<Node> = ids
|
||||
@@ -3511,18 +3588,24 @@ fn clone_subtree_from_doc(
|
||||
.iter()
|
||||
.filter_map(|x| map.get(x).copied())
|
||||
.collect();
|
||||
if c.component && c.component_key.is_some() {
|
||||
if c.component && c.component_key.is_some() && !keep_keys {
|
||||
c.component_key = Some(crate::variables::new_id());
|
||||
}
|
||||
// Only a same-page link can point into the copied subtree; cross-page and library links
|
||||
// keep their main.
|
||||
if let Some(l) = &mut c.instance
|
||||
&& l.source.is_none()
|
||||
{
|
||||
if let Some(m) = map.get(&l.main) {
|
||||
l.main = *m;
|
||||
} else if !tx.doc().get(l.main).is_some_and(|mn| mn.component) {
|
||||
c.instance = None;
|
||||
// Only a same-page link can point into the copied subtree; one to a main left on the
|
||||
// source page becomes a cross-page link, one to the destination page a same-page link.
|
||||
if let Some(l) = &mut c.instance {
|
||||
match &l.source {
|
||||
None => {
|
||||
if let Some(m) = map.get(&l.main) {
|
||||
l.main = *m;
|
||||
} else if let Some((from, _)) = pages {
|
||||
l.source = Some(Source::Page(from.to_owned()));
|
||||
} else if !tx.doc().get(l.main).is_some_and(|mn| mn.component) {
|
||||
c.instance = None;
|
||||
}
|
||||
}
|
||||
Some(Source::Page(p)) if pages.is_some_and(|(_, to)| to == p) => l.source = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let NodeKind::Connector { start, end, .. } = &mut c.kind {
|
||||
@@ -3747,6 +3830,17 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
};
|
||||
let flex = parent.layout.is_some();
|
||||
let made = b.nodes(&tree, &inherited, &parent, &mut index, flex);
|
||||
// An instance written over a nested instance becomes a swap override: it keeps the
|
||||
// old node's place in the outer instance, so the next sync of the outer main keeps it.
|
||||
if let (Some(o), [id]) = (&old, made.as_slice())
|
||||
&& let Some(place) = o.instance.clone()
|
||||
&& parent_id.and_then(|p| b.tx.doc().get(p)).is_some_and(|p| p.instance.is_some())
|
||||
&& let Some(n) = b.tx.node_mut(*id)
|
||||
&& let Some(l) = n.instance.as_mut()
|
||||
{
|
||||
let to = l.swap.take().unwrap_or(Swap { main: l.main, source: l.source.take() });
|
||||
*l = InstanceLink { main: place.main, source: place.source, swap: Some(to), overrides: std::mem::take(&mut l.overrides) };
|
||||
}
|
||||
// Root-level content without a position goes where the replaced node was, or
|
||||
// into free space.
|
||||
if parent_id.is_none() {
|
||||
@@ -4667,7 +4761,9 @@ fn get_components(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
for page in 0..ed.pages.len() {
|
||||
for n in ed.page_doc(page).nodes.values() {
|
||||
let Some(l) = &n.instance else { continue };
|
||||
let main_page = match &l.source {
|
||||
// A swapped nested instance counts for the component it was swapped to.
|
||||
let (main, source) = l.target();
|
||||
let main_page = match source {
|
||||
None => page,
|
||||
Some(Source::Page(p)) => match ed.pages.iter().position(|s| s.id == *p) {
|
||||
Some(i) => i,
|
||||
@@ -4675,8 +4771,8 @@ fn get_components(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
},
|
||||
Some(Source::Library(_)) => continue,
|
||||
};
|
||||
if n.id != l.main || main_page != page {
|
||||
*uses.entry((main_page, l.main)).or_default() += 1;
|
||||
if n.id != main || main_page != page {
|
||||
*uses.entry((main_page, main)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5052,17 +5148,22 @@ fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
&& n.parent
|
||||
.and_then(|p| ed.doc.get(p))
|
||||
.is_none_or(|p| p.instance.is_none());
|
||||
if !is_root {
|
||||
if n.instance.is_none() {
|
||||
return Err(format!(
|
||||
"`{}` is not a component instance root",
|
||||
"`{}` is not a component instance",
|
||||
a.req_str("nodeId").unwrap_or_default()
|
||||
));
|
||||
}
|
||||
if cpage == page && lib.is_none() && (inst == new_main || ed.doc.is_ancestor(inst, new_main)) {
|
||||
return Err("could not swap instance to that component".into());
|
||||
}
|
||||
// A nested instance (inside another instance) keeps its place in it: a swap override.
|
||||
let ok = ed.edit("Swap instance", |tx| {
|
||||
crate::editor::structure::swap_instance_tx(tx, src_doc.as_ref(), source, inst, new_main)
|
||||
if is_root {
|
||||
crate::editor::structure::swap_instance_tx(tx, src_doc.as_ref(), source, inst, new_main)
|
||||
} else {
|
||||
crate::editor::structure::swap_nested_tx(tx, src_doc.as_ref(), source, inst, new_main)
|
||||
}
|
||||
});
|
||||
if !ok {
|
||||
return Err("could not swap instance to that component".into());
|
||||
@@ -5295,6 +5396,72 @@ fn search_icons(a: &Args) -> ToolResult {
|
||||
text(v)
|
||||
}
|
||||
|
||||
/// Link a main component (or the main of an instance) to its code in a UI library.
|
||||
fn set_code_link(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
|
||||
let doc = ed.page_doc(page);
|
||||
let n = doc.get(id).ok_or("no such node")?;
|
||||
let main = match (&n.instance, n.component) {
|
||||
(_, true) => id,
|
||||
(Some(l), false) if l.target().1.is_none() && doc.get(l.target().0).is_some() => l.target().0,
|
||||
(Some(_), false) => return Err("the main component is on another page: link it there".into()),
|
||||
(None, false) => return Err("set_code_link needs a main component or an instance of one (create_component first)".into()),
|
||||
};
|
||||
let s = |k: &str| a.str(k).unwrap_or("").to_owned();
|
||||
let link = crate::model::CodeLink { kit: s("kit"), import: s("import"), react: s("react"), egui: s("egui") };
|
||||
let clear = link.import.is_empty() && link.react.is_empty() && link.egui.is_empty();
|
||||
let name = doc.get(main).map(|m| m.name.clone()).unwrap_or_default();
|
||||
ed.with_page(page, |ed| {
|
||||
ed.edit("Set code link", |tx| {
|
||||
tx.node_mut(main).unwrap().code = (!clear).then_some(link);
|
||||
})
|
||||
});
|
||||
text(json!({"component": node_key(ed, page, main), "name": name, "linked": !clear}))
|
||||
}
|
||||
|
||||
/// The JSON a tool call answers with (its first text block).
|
||||
fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, String> {
|
||||
let outs = call(host, name, &args)?;
|
||||
let t = outs.iter().find_map(|o| if let Out::Text(t) = o { Some(t.as_str()) } else { None }).ok_or_else(|| format!("{name} answered no text"))?;
|
||||
serde_json::from_str(t).map_err(|e| format!("{name}: {e}"))
|
||||
}
|
||||
|
||||
/// Build a "UI Kit / <name>" page from a kit (`crate::ui_kits`): its variables (Light and Dark),
|
||||
/// tokens, and its components, each a main component linked to the library's code.
|
||||
fn apply_ui_kit(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
let id = a.req_str("kit")?;
|
||||
let Some(kit) = crate::ui_kits::kit(id) else {
|
||||
let all: Vec<&str> = crate::ui_kits::KITS.iter().map(|k| k.id).collect();
|
||||
return Err(format!("no kit `{id}` (kits: {})", all.join(", ")));
|
||||
};
|
||||
let map = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<String, Value>>();
|
||||
let page = call_json(host, "create_page", json!({"name": format!("UI Kit / {}", kit.name)}))?;
|
||||
let pid = page["pageId"].as_str().ok_or("create_page gave no page")?.to_owned();
|
||||
call_json(host, "set_variables", json!({"pageId": pid, "variables": map(kit.light), "mode": "Light"}))?;
|
||||
call_json(host, "set_variables", json!({"pageId": pid, "variables": map(kit.dark), "mode": "Dark"}))?;
|
||||
call_json(host, "set_tokens", json!({"pageId": pid, "tokens": map(kit.tokens)}))?;
|
||||
let body: String = kit.components.iter().map(|c| c.html).collect();
|
||||
let html = format!(
|
||||
r#"<div layer-name="UI Kit / {}" style="display:flex;flex-wrap:wrap;gap:32px;align-items:flex-start;padding:48px;width:1200px;background:var(--{})">{body}</div>"#,
|
||||
kit.name, kit.surface
|
||||
);
|
||||
let written = call_json(host, "write_html", json!({"pageId": pid, "targetNodeId": "root", "html": html}))?;
|
||||
let board = written["createdNodeIds"][0].as_str().ok_or("write_html made no frame")?.to_owned();
|
||||
let kids = call_json(host, "get_children", json!({"pageId": pid, "nodeId": board}))?;
|
||||
let mut made = Vec::new();
|
||||
for c in kit.components {
|
||||
let Some(node) = kids["children"].as_array().into_iter().flatten().find(|k| k["name"] == c.name).and_then(|k| k["id"].as_str()) else { continue };
|
||||
let comp = call_json(host, "create_component", json!({"pageId": pid, "nodeId": node}))?;
|
||||
let cid = comp["id"].as_str().unwrap_or(node).to_owned();
|
||||
call_json(host, "set_code_link", json!({"pageId": pid, "nodeId": cid, "kit": kit.id, "import": c.import, "react": c.react, "egui": c.egui}))?;
|
||||
made.push(json!({"name": c.name, "id": cid}));
|
||||
}
|
||||
text(json!({
|
||||
"pageId": pid, "frame": board, "kit": kit.id, "components": made,
|
||||
"next": "Place these with <x-paper-instance component=\"Name\" overrides='{\"Label\":\"Text\"}'/>; the code export and the AI spec use the library components for them. Use the kit's variables (var(--primary) and so on) for everything else."
|
||||
}))
|
||||
}
|
||||
|
||||
fn rename_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let n = batch(
|
||||
ed,
|
||||
@@ -5410,6 +5577,7 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
})
|
||||
} else {
|
||||
let src_doc = ed.page_doc(src_page).clone();
|
||||
let ids = (ed.pages[src_page].id.clone(), ed.pages[dest_page].id.clone());
|
||||
ed.with_page(dest_page, |ed| {
|
||||
ed.edit("Duplicate", |tx| {
|
||||
let n = src_doc.get(id).unwrap();
|
||||
@@ -5420,7 +5588,7 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
} else {
|
||||
-w.min()
|
||||
};
|
||||
let r = clone_subtree_from_doc(tx, &src_doc, id, target, None, offset);
|
||||
let r = clone_subtree_from_doc(tx, &src_doc, id, target, None, offset, Some((&ids.0, &ids.1)), false);
|
||||
copy_annotations(tx, &src_doc.annotations, &r.1);
|
||||
relayout(tx, target.or(Some(r.0)));
|
||||
r
|
||||
@@ -5545,13 +5713,20 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
return Err("the destination can't have children".into());
|
||||
}
|
||||
let src_doc = ed.page_doc(src_page).clone();
|
||||
let ids = (ed.pages[src_page].id.clone(), ed.pages[dest_page].id.clone());
|
||||
// Notes on the moved nodes go with them instead of staying behind detached.
|
||||
let sub: HashSet<NodeId> = src_doc.subtree(id).into_iter().collect();
|
||||
let notes: Vec<u64> = src_doc.annotations.iter().filter(|n| n.target.is_some_and(|t| sub.contains(&t))).map(|n| n.id).collect();
|
||||
ed.with_page(src_page, |ed| {
|
||||
ed.edit("Move", |tx| {
|
||||
for n in ¬es {
|
||||
tx.delete_annotation(*n);
|
||||
}
|
||||
tx.remove(id);
|
||||
});
|
||||
ed.selection.retain(|s| ed.doc.get(*s).is_some());
|
||||
});
|
||||
ed.with_page(dest_page, |ed| {
|
||||
let pairs = ed.with_page(dest_page, |ed| {
|
||||
ed.edit("Move", |tx| {
|
||||
let w = src_doc
|
||||
.world_rect(id)
|
||||
@@ -5571,17 +5746,51 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
.map(|p| p + usize::from(after.is_some())),
|
||||
None => Some(index.unwrap_or(list_len).min(list_len)),
|
||||
};
|
||||
let (new_root, _) =
|
||||
clone_subtree_from_doc(tx, &src_doc, id, target, idx, offset);
|
||||
let (new_root, pairs) =
|
||||
clone_subtree_from_doc(tx, &src_doc, id, target, idx, offset, Some((&ids.0, &ids.1)), true);
|
||||
copy_annotations(tx, &src_doc.annotations, &pairs);
|
||||
relayout(tx, target.or(Some(new_root)));
|
||||
});
|
||||
pairs
|
||||
})
|
||||
});
|
||||
relink_moved(ed, src_page, dest_page, &pairs.into_iter().collect());
|
||||
}
|
||||
done += 1;
|
||||
}
|
||||
text(json!({"moved": done}))
|
||||
}
|
||||
|
||||
/// After nodes moved from `src` to `dest` page (`moved`: old id to new id), instances anywhere
|
||||
/// that used them as their main follow them, so moving components keeps every instance linked.
|
||||
fn relink_moved(ed: &mut Editor, src: usize, dest: usize, moved: &HashMap<NodeId, NodeId>) {
|
||||
ed.finish_loading();
|
||||
let (src_id, dest_id) = (ed.pages[src].id.clone(), ed.pages[dest].id.clone());
|
||||
for page in 0..ed.pages.len() {
|
||||
// On the source page the links were same-page; elsewhere they named the source page.
|
||||
let was = if page == src { None } else { Some(Source::Page(src_id.clone())) };
|
||||
let now = if page == dest { None } else { Some(Source::Page(dest_id.clone())) };
|
||||
let hits: Vec<NodeId> = ed
|
||||
.page_doc(page)
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.instance.as_ref().is_some_and(|l| l.source == was && moved.contains_key(&l.main)))
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
ed.with_page(page, |ed| {
|
||||
ed.edit("Move", |tx| {
|
||||
for id in hits {
|
||||
let l = tx.node_mut(id).unwrap().instance.as_mut().unwrap();
|
||||
l.main = moved[&l.main];
|
||||
l.source = now.clone();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn select_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let page = ed.active_page;
|
||||
let (p, ids) = resolve_all(ed, &a.strs("nodeIds")?, page)?;
|
||||
|
||||
@@ -155,8 +155,16 @@ fn run_command(template: &str, prompt: &str, aspect: &str) -> Result<Vec<PathBuf
|
||||
let out = std::env::temp_dir().join(format!("papercraft-imagegen-{}-{}-{:08x}", std::process::id(), crate::project::format::now_ms(), fastrand::u32(..)));
|
||||
std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
|
||||
let script = shell_script(template);
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", &script])
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.args(["/C", &script]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.args(["-c", &script]);
|
||||
c
|
||||
};
|
||||
let mut child = cmd
|
||||
.env("PAPERCRAFT_PROMPT", prompt)
|
||||
.env("PAPERCRAFT_ASPECT", aspect)
|
||||
.env("PAPERCRAFT_RATIO", ratio(aspect))
|
||||
@@ -230,6 +238,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_custom_command_gets_the_prompt_as_data_not_as_shell() {
|
||||
// The prompt tries to run a command; it must arrive as plain text.
|
||||
let prompt = "dragon\"; touch pwned; echo \"";
|
||||
|
||||
@@ -22,12 +22,15 @@ pub mod lint;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod imagegen;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod delegate;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod refs;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod relay;
|
||||
pub mod relay_proto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
pub mod steer;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod store;
|
||||
pub mod tools;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Messages the user adds while a chat turn is working. Each goes out with the next PaperCraft
|
||||
//! tool result for that chat, so any backend sees it mid-turn without starting a new turn; what
|
||||
//! no tool call picked up is sent as the next message when the turn ends.
|
||||
|
||||
use super::{Client, Out, ToolResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static QUEUE: Mutex<Option<HashMap<String, Vec<String>>>> = Mutex::new(None);
|
||||
|
||||
pub fn add(chat: &str, text: &str) {
|
||||
QUEUE.lock().unwrap().get_or_insert_default().entry(chat.to_owned()).or_default().push(text.to_owned());
|
||||
}
|
||||
|
||||
/// What is still waiting for the agent in `chat`.
|
||||
pub fn pending(chat: &str) -> Vec<String> {
|
||||
QUEUE.lock().unwrap().as_ref().and_then(|q| q.get(chat).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn take(chat: &str) -> Vec<String> {
|
||||
QUEUE.lock().unwrap().as_mut().and_then(|q| q.remove(chat)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A chat's tool result with the messages the user added since, for the agent to read now.
|
||||
pub fn attach(client: &Client, r: ToolResult) -> ToolResult {
|
||||
if client.name != super::CHAT_CLIENT {
|
||||
return r;
|
||||
}
|
||||
let mut outs = r?;
|
||||
let added = take(&client.session);
|
||||
if !added.is_empty() {
|
||||
outs.push(Out::Text(format!(
|
||||
"[The user added this while you work. Take it into account from now on, then go on with the task.]\n{}",
|
||||
added.join("\n\n")
|
||||
)));
|
||||
}
|
||||
Ok(outs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn added_messages_ride_on_the_next_tool_result_of_that_chat_only() {
|
||||
add("steer-a", "make it blue");
|
||||
let other = attach(&Client::chat("steer-b"), Ok(vec![])).unwrap();
|
||||
assert!(other.is_empty());
|
||||
let mcp = Client { name: "Claude Code".into(), session: "steer-a".into() };
|
||||
assert!(attach(&mcp, Ok(vec![])).unwrap().is_empty(), "only chat turns");
|
||||
let outs = attach(&Client::chat("steer-a"), Ok(vec![])).unwrap();
|
||||
assert!(matches!(&outs[..], [Out::Text(t)] if t.ends_with("make it blue")));
|
||||
assert!(pending("steer-a").is_empty(), "delivered once");
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
|
||||
&& !m.contains_key("fileId")
|
||||
&& !matches!(
|
||||
name,
|
||||
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image"
|
||||
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate"
|
||||
)
|
||||
{
|
||||
m.insert("fileId".into(), file_id());
|
||||
@@ -145,6 +145,14 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"rename_file",
|
||||
"Renames a project: its name and its folder. Without fileId, the open project (saved \
|
||||
first; it stays open). Returns the new folder path.",
|
||||
json!({"name": {"type": "string", "description": "The new project name."}}),
|
||||
&["name"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"create_page",
|
||||
"Creates a new page at the end of the page list and returns its id, without switching \
|
||||
@@ -307,7 +315,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
"ask_user",
|
||||
"Ask the user a few questions as a card with clickable answers (in-app chat). Use it \
|
||||
for the brief before a new design (platform and frame size, density, palette, type, \
|
||||
references, scope) and for next steps after one. One to five questions, two to six \
|
||||
references, scope) and for next steps after one. One to seven questions, two to six \
|
||||
options each, the option you recommend first. A palette option lists its hex colors \
|
||||
in `colors` (shown as swatches); a reference option gives its saved `image` path. The user can also type their own answer. After \
|
||||
calling it, end your turn: the answers come as the user's next message.",
|
||||
@@ -324,6 +332,75 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["questions"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"report_issue",
|
||||
"Draft an issue about PaperCraft itself (a tool that misbehaved, docs that disagree \
|
||||
with the app, a capability you had to work around). The user sees the draft and \
|
||||
sends it to PaperCraft's repository or discards it. Use a minimal synthetic repro: \
|
||||
never the user's own content, names or files.",
|
||||
json!({
|
||||
"kind": {"type": "string", "enum": ["bug", "docs", "feature"]},
|
||||
"title": {"type": "string", "description": "Short, specific: the tool and what goes wrong."},
|
||||
"body": {"type": "string", "description": "**Expected:** … **Actual:** … **Repro:** the smallest tool call that shows it. **Workaround:** … **Impact:** …"},
|
||||
}),
|
||||
&["kind", "title", "body"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"delegate",
|
||||
"Hand a task to the helper agent the user set up (a cheaper model with PaperCraft's CLI, \
|
||||
weaker at design): `qa` reviews frames and returns findings, `read` reads many pages, \
|
||||
frames, specs or URLs and returns key points, `build` makes edits you fully specify. \
|
||||
It does not see this chat: the task carries everything (frame names or ids, the brief, \
|
||||
the variables and components to use, exact markup for a build). Only its short answer \
|
||||
comes back.",
|
||||
json!({
|
||||
"role": {"type": "string", "enum": ["qa", "read", "build"]},
|
||||
"task": {"type": "string", "description": "The complete, self-contained instructions."},
|
||||
}),
|
||||
&["role", "task"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"design_rules",
|
||||
"Look up design-playbook rules (interaction, accessibility, motion, forms, tokens, \
|
||||
component patterns, handoff annotations; each with a stable id like A11Y-09) through \
|
||||
the `playbook` CLI. Use only when the user agreed to follow the playbook. Give \
|
||||
`query` for ranked matches or `rule` for an id or prefix (\"ANN\"). Cite the ids \
|
||||
you applied in your report.",
|
||||
json!({
|
||||
"query": {"type": "string", "description": "Words to search, e.g. \"confirm vs undo delete\"."},
|
||||
"rule": {"type": "string", "description": "A rule id (\"INT-06\") or prefix (\"MOT\")."},
|
||||
}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"apply_ui_kit",
|
||||
"Start a design in a UI library's look and code: builds a \"UI Kit / <name>\" page \
|
||||
with the kit's variables (Light and Dark modes), tokens and components, each a main \
|
||||
component linked to the library (so the code export and the AI spec write \
|
||||
<Button variant=\"outline\"> instead of rebuilding divs). Kits: shadcn (shadcn/ui), \
|
||||
antd (Ant Design), reactbits (React Bits animated pieces), egui (native Rust widgets).",
|
||||
json!({"kit": {"type": "string", "enum": ["shadcn", "antd", "reactbits", "egui"]}}),
|
||||
&["kit"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_code_link",
|
||||
"Link a main component (or an instance's main) to its code in a library, used by the \
|
||||
code export and the AI spec. In templates {Name} is the text of the instance's layer \
|
||||
named Name. Empty import, react and egui remove the link.",
|
||||
json!({
|
||||
"nodeId": node_id("A main component or one of its instances."),
|
||||
"kit": {"type": "string", "description": "Library, like shadcn, antd, mui, chakra, reactbits, egui."},
|
||||
"import": {"type": "string", "description": "React import line."},
|
||||
"react": {"type": "string", "description": "JSX, like <Button variant=\"outline\">{Label}</Button>."},
|
||||
"egui": {"type": "string", "description": "Rust on `ui`, like ui.button({Label});"},
|
||||
}),
|
||||
&["nodeId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"generate_image",
|
||||
"Make images with the Codex CLI's image generation (the user's Codex login): art the \
|
||||
@@ -790,10 +867,12 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"swap_instance",
|
||||
"Swap an instance root to point at a different main component, preserving overrides \
|
||||
on descendants matched by name and layer type. componentKey may be a component on \
|
||||
any page or an enabled library; no import needed.",
|
||||
json!({"nodeId": node_id("The instance root node to swap."),
|
||||
"Swap an instance to point at a different main component. An instance root keeps \
|
||||
overrides on descendants matched by name and layer type. A nested instance (a badge \
|
||||
inside a row instance) becomes a swap override: it keeps its place, and later edits \
|
||||
to the outer main keep the swap; set its texts after. componentKey may be a component \
|
||||
on any page or an enabled library; no import needed.",
|
||||
json!({"nodeId": node_id("The instance (root or nested) to swap."),
|
||||
"componentId": node_id("The new main component id."),
|
||||
"componentKey": {"type": "string", "description": "Instead of componentId: a key from get_components, including a library component."},
|
||||
"pageId": page_id()}),
|
||||
@@ -1018,11 +1097,11 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
Some(match name {
|
||||
"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" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "find_nodes" | "get_font_family_info"
|
||||
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info"
|
||||
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
|
||||
| "diff_frames" | "publish_library" | "list_libraries" => false,
|
||||
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
|
||||
| "create_file" | "finish_working_on_nodes" => false,
|
||||
| "create_file" | "rename_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"
|
||||
@@ -1030,7 +1109,7 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
| "create_collection" | "delete_variables" | "bind_variable"
|
||||
| "enable_library" | "update_library" | "disable_library"
|
||||
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
|
||||
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes"
|
||||
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes" | "apply_ui_kit" | "set_code_link"
|
||||
| "duplicate_nodes" | "move_nodes" | "undo" | "redo" => true,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod rough;
|
||||
|
||||
pub use document::{Document, VarState};
|
||||
pub use node::{
|
||||
Binding, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
|
||||
Binding, CodeLink, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
|
||||
StrokePoint, Style, SubPath, TextAlign,
|
||||
};
|
||||
pub use paint::*;
|
||||
|
||||
@@ -429,6 +429,27 @@ pub struct Node {
|
||||
/// Stable key of a main component (set on `create_component`), used by instances on other pages.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub component_key: Option<String>,
|
||||
/// A main component's code in a UI library, used by the code export and the AI spec
|
||||
/// (`set_code_link`, `apply_ui_kit`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<CodeLink>,
|
||||
}
|
||||
|
||||
/// What a component is in code: its library, the import, and a usage template per target.
|
||||
/// `{Name}` in a template is the text of the instance's layer named `Name`.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct CodeLink {
|
||||
/// The kit or library, like `shadcn`, `antd`, `reactbits`, `egui`.
|
||||
pub kit: String,
|
||||
/// The React import line, like `import { Button } from "@/components/ui/button"`.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub import: String,
|
||||
/// JSX for React and Tailwind exports.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub react: String,
|
||||
/// Rust for the egui export (statements on `ui`).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub egui: String,
|
||||
}
|
||||
|
||||
/// A note attached to a layer: what to change, from the user to an agent or back.
|
||||
@@ -533,6 +554,7 @@ impl Node {
|
||||
link: None,
|
||||
bindings: BTreeMap::new(),
|
||||
component_key: None,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -635,6 +635,25 @@ pub struct InstanceLink {
|
||||
/// Where the main lives. `None` = same page as this instance.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<Source>,
|
||||
/// A nested instance swapped to another component inside its instance: it keeps its place
|
||||
/// (`main`) and takes its content from this component.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swap: Option<Swap>,
|
||||
}
|
||||
|
||||
impl InstanceLink {
|
||||
/// The main this node takes its content from, and where it lives: the swap when there is one.
|
||||
pub fn target(&self) -> (NodeId, &Option<Source>) {
|
||||
self.swap.as_ref().map_or((self.main, &self.source), |s| (s.main, &s.source))
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a nested instance was swapped to, and where it lives (`None` = this page).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Swap {
|
||||
pub main: NodeId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<Source>,
|
||||
}
|
||||
|
||||
/// The document holding a main component, when it is not the instance's own page.
|
||||
|
||||
@@ -113,6 +113,16 @@ pub fn folder_name(name: &str) -> String {
|
||||
if s.is_empty() { "Untitled".into() } else { s }
|
||||
}
|
||||
|
||||
/// Where the app keeps its chat settings: `$PAPERCRAFT_HOME/.config` when set (so a second
|
||||
/// copy for testing has its own), else `papercraft` in the OS config folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
match std::env::var_os("PAPERCRAFT_HOME") {
|
||||
Some(h) => Some(PathBuf::from(h).join(".config")),
|
||||
None => dirs::config_dir().map(|d| d.join("papercraft")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// `$PAPERCRAFT_HOME`, else `~/Documents/papercraft` (`~/Documents/paper-rs` when only that exists).
|
||||
pub fn default_root() -> PathBuf {
|
||||
|
||||
@@ -265,6 +265,16 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Save the camera too (on close / leaving the project), plus everything dirty.
|
||||
/// Rename the open project: saved first, then its manifest and folder follow the new name.
|
||||
pub fn rename_open(&mut self, ed: &mut Editor, name: &str) -> Result<std::path::PathBuf, String> {
|
||||
self.save_all(ed).map_err(|e| format!("could not save the open project: {e}"))?;
|
||||
let p = self.project.as_mut().ok_or("no project is open")?;
|
||||
let dir = self.store.rename(&p.dir, name).map_err(|e| e.to_string())?;
|
||||
p.dir = dir.clone();
|
||||
p.manifest.name = name.trim().to_owned();
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn save_all(&mut self, ed: &mut Editor) -> Result<(), FormatError> {
|
||||
self.save(ed)?;
|
||||
if let Some(p) = &self.project {
|
||||
|
||||
@@ -626,6 +626,8 @@ pub enum Icon {
|
||||
More,
|
||||
Page,
|
||||
Restore,
|
||||
/// Present (play).
|
||||
Play,
|
||||
Group,
|
||||
Pen,
|
||||
Polygon,
|
||||
@@ -1180,6 +1182,13 @@ pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color3
|
||||
start + vec2(3.0 * u, 0.3 * u),
|
||||
]);
|
||||
}
|
||||
Icon::Play => {
|
||||
// Lucide's play, with its corners softened like the stroked icons around it.
|
||||
closed(&[pt(4.5, 2.5), pt(13.0, 8.0), pt(4.5, 13.5)]);
|
||||
for p in [pt(4.5, 2.5), pt(13.0, 8.0), pt(4.5, 13.5)] {
|
||||
painter.circle_filled(p, w / 2.0, color);
|
||||
}
|
||||
}
|
||||
Icon::Export => {
|
||||
line(&[
|
||||
pt(5.0, 6.5),
|
||||
|
||||
@@ -52,22 +52,37 @@ const RESEARCH_LINE: &str = "[Research] Web research is on";
|
||||
/// How the in-app chat differs from an agent in a terminal: the user is right here, and every
|
||||
/// token is theirs (specs/chat-tokens).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Added when the design-playbook CLI is installed: the agent reaches it through `design_rules`.
|
||||
const PLAYBOOK_RULE: &str = "\n\nDesign playbook: the design-playbook rules are available through design_rules. Add a seventh question to the brief: follow the playbook (recommended) or not. If yes, before building look up the rules for what you design (for example the screen's patterns, forms, states, accessibility, motion) with one or two design_rules calls, follow them, and cite the rule ids you applied in the finish report. Thresholds (contrast, target sizes, durations) come from the playbook, not memory.";
|
||||
|
||||
/// Added in auto mode: nobody is there to answer, the first option of every question is taken.
|
||||
const AUTO_RULE: &str = "\n\nAuto mode is on: the user is away. Every ask_user call is answered at once with the first option of each question, so always put the option you recommend first, and never ask for what only the user can know (brand assets, real data, credentials): choose a sensible default and say which. End every turn with ask_user next steps, so the work goes on.";
|
||||
|
||||
/// Added when the user set up a helper agent (Settings, Chat, Helper): the chat agent orchestrates.
|
||||
const HELPER_RULE: &str = "\n\nHelper. A helper agent on a cheaper model is available through delegate; you orchestrate. Keep the brief, design decisions and anything that needs taste. Hand it: qa after each built screen; read when the work needs many pages, frames, specs or references read (it returns key points; do not reread what it read); build for repetitive or fully specified edits (the same row in ten places, filling a table, applying given markup), with the variables, components and exact structure written into the task. It does not see this chat, so each task is self-contained. Check a build with one narrow read or a qa pass, not by redoing it.";
|
||||
|
||||
/// Characters of an added message shown in the composer's queue.
|
||||
const ADDED_PREVIEW: usize = 80;
|
||||
|
||||
const CHAT_RULES: &str = "In-app chat: the user is watching and can answer at once.
|
||||
|
||||
Brief. Before the first design in a project or on an empty page, ask first, even when the request looks specific, unless the user said to go ahead:
|
||||
* Call ask_user once, up to five questions, your recommended option first: platform and frame size (desktop 1440x900, laptop 1280x800, 1440p 2560x1440, tablet 768x1024, phone 390x844); density (compact, 32px controls for a desktop pointer UI; comfortable, 40px; touch, 44px and up); palette (two or three palettes that suit the request, with their hex colors, plus \"my brand colors\"); type (a pairing that suits it, or Inter only); scope (one screen to judge the look, or a flow).
|
||||
* Call ask_user once, up to six questions, your recommended option first: platform and frame size (desktop 1440x900, laptop 1280x800, 1440p 2560x1440, tablet 768x1024, phone 390x844); density (compact, 32px controls for a desktop pointer UI; comfortable, 40px; touch, 44px and up); palette (two or three palettes that suit the request, with their hex colors, plus \"my brand colors\"); type (a pairing that suits it, or Inter only); scope (one screen to judge the look, or a flow); code library (shadcn/ui, Ant Design, React Bits on top of either, egui for a Rust app, or none).
|
||||
* In the same message invite references: they can paste or drop screenshots and images in the chat. Then end your turn. Never restate a card in text: one short sentence at most next to it.
|
||||
|
||||
Build.
|
||||
* A code library chosen: call apply_ui_kit with it first and build screens from its components (<x-paper-instance>), restyling its variables to the agreed palette, so the code handoff writes the library's components. A component of your own that maps to a library one: set_code_link it.
|
||||
* Variables first: colors (light and dark modes) with set_variables, spacing, radius and type sizes with set_tokens, then write every style with var(--name), no literal colors or off-scale sizes. For more than one screen put them and the components on a Design System page first.
|
||||
* Anything that repeats (a card, a row, a button, a slot) is a component, built once and placed as instances, with variants for its states. Once a component exists, place it inside write_html with <x-paper-instance component=\"Name\" overrides='{\"Label\":\"Text\"}'/> instead of writing its markup again: rewritten markup is most of what a turn costs.
|
||||
* Sizes follow the agreed density. WCAG 2.2 AA asks 24px targets for a pointer, so compact desktop controls are fine; touch screens need 44px.
|
||||
* Few, large steps: one write_html per region or per set of components, not one per element. Read narrowly: get_tree_summary or find_nodes on the frame you change, not the page; get_jsx only for what you will copy. One screenshot per screen you changed, at the end, after one lint: every image stays in the context and is re-read on each later step.
|
||||
* Review. When a qa agent (Agent tool) or delegate with role qa is available, hand the end-of-turn lint and screenshot check to it instead (delegate first when on: it is cheaper), naming the frames and the brief; fix what it reports with narrow reads, and screenshot yourself only what a finding leaves unclear.
|
||||
|
||||
Finish. Report in at most 80 words, flat bullets: what you built, what is a component, what the user should confirm. Then call ask_user with two to four next steps (for example: turn the repeats into components, add the light mode, a mobile layout, the next screen).
|
||||
|
||||
Research. When a message carries the [Research] line, web search is on. If a researcher agent is available, hand all reference research to it (one Agent call with what the design is and the brief): it searches and fetches on a cheaper model and returns only the kept images; then skip to asking which to follow. Otherwise, after the brief is answered (or when asked), find three to six references that show an interface, not marketing. Sources that work: nicelydone.club/apps/<app> (real screenshots of 500+ SaaS web apps: linear, notion, slack, jira...), banani.co/references/apps and screenlane.com (mobile screens), App Store and Google Play listings (app screenshots), godly.website and awwwards.com/websites (marketing sites). Dribbble, Behance, Figma Community, Mobbin, Land-book and Lapa block automated fetching; a homepage's image is marketing art. Bring them in with one fetch_reference_images call, then look at what came back: keep only images that show an interface like the one you are designing (a screen, its layout or its components). A social card, a logo, an app icon or an illustration is not a reference. If fewer than two qualify, search again once with better sources (the product name plus \"screenshot\", its App Store listing, a help-center article) before building. Say in one line each what you take from a kept one (layout, density, color, type), then ask_user which to follow with only the kept ones as options, each with its `image` path, even when the user took your defaults. Take ideas, never a brand's logo or artwork.
|
||||
|
||||
* When PaperCraft itself gets in the way (a tool misbehaves, its docs disagree with what it does, or you had to work around a missing capability), call report_issue once per problem with a minimal synthetic repro, never the user's content, then go on. The user decides whether it is sent.
|
||||
* Less common tools (annotations, pages, files, export, libraries, design-system and component edits) may not be loaded yet: find them with tool search.
|
||||
* Attached images are scaled down to send. To read small text or detail in one, call zoom_image with its attached path and a region instead of guessing.";
|
||||
const SELF_FOLLOW_QUIET: Duration = Duration::from_secs(10);
|
||||
@@ -256,6 +271,28 @@ pub struct SelChip {
|
||||
pub image: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// An issue for PaperCraft: kind (`bug`, `docs`, `feature`), title and body.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct IssueDraft {
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Where an issue draft stands.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum IssueState {
|
||||
Sending,
|
||||
/// Filed as #number; `existing` when an issue with a matching title was there already.
|
||||
Filed { number: u64, url: String, existing: bool },
|
||||
Failed(String),
|
||||
Discarded,
|
||||
}
|
||||
|
||||
/// The key of the form's issue in `ChatState::issues`.
|
||||
pub const FORM_ISSUE: &str = "form";
|
||||
const ISSUE_TEMPLATE: &str = "**Expected:** \n**Actual:** \n**Repro:** \n**Workaround:** \n**Impact:** ";
|
||||
|
||||
/// An image waiting in the composer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Attachment {
|
||||
@@ -300,6 +337,10 @@ pub struct ChatState {
|
||||
pub rx: Option<Receiver<Event>>,
|
||||
/// Chat the running turn belongs to: its events go there, whichever chat is open.
|
||||
turn_chat: Option<String>,
|
||||
/// Messages added during the running turn (`mcp::steer`), shown until it ends.
|
||||
pub added: Vec<String>,
|
||||
/// Added messages no tool call picked up: sent as the next message once the turn ends.
|
||||
followup: Option<String>,
|
||||
/// Stop was pressed; the turn stays occupied until the backend's last event.
|
||||
pub stopping: bool,
|
||||
/// The last turn ended because the user stopped it: the list says "Stopped", not an error.
|
||||
@@ -317,6 +358,12 @@ pub struct ChatState {
|
||||
turn_read: u64,
|
||||
/// The last turn was stopped by the turn budget (`ChatPrefs::turn_budget`).
|
||||
pub budget_hit: bool,
|
||||
/// Issues being filed or filed, by key (a `report_issue` call's id, or `FORM_ISSUE`).
|
||||
pub issues: std::collections::HashMap<String, IssueState>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
issue_rx: Vec<(String, std::sync::mpsc::Receiver<Result<crate::issues::Filed, String>>)>,
|
||||
/// The "Report a PaperCraft issue" form, while open.
|
||||
pub issue_form: Option<IssueDraft>,
|
||||
pub approvals: Vec<Approval>,
|
||||
/// Streamed thinking of the last turn (the "Thinking" row; not saved).
|
||||
pub thinking: String,
|
||||
@@ -402,7 +449,7 @@ struct Prefs {
|
||||
impl Prefs {
|
||||
fn path() -> Option<PathBuf> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return dirs::config_dir().map(|d| d.join("papercraft").join("chat_ui.json"));
|
||||
return crate::project::config_dir().map(|d| d.join("chat_ui.json"));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
@@ -496,6 +543,10 @@ impl ChatState {
|
||||
t.stop();
|
||||
self.stopping = true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(chat) = &self.turn_chat {
|
||||
crate::mcp::delegate::stop(chat);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once: whether the app should close the open agent run (the turn that edited is over).
|
||||
@@ -674,7 +725,20 @@ impl ChatState {
|
||||
self.stop();
|
||||
}
|
||||
if done {
|
||||
let turn = self.turn_chat.clone();
|
||||
self.finish_turn();
|
||||
// Auto mode: questions the turn ended on get the recommended answers, and it goes on.
|
||||
if self.followup.is_none()
|
||||
&& self.prefs.auto
|
||||
&& !self.stopped
|
||||
&& !self.budget_hit
|
||||
&& let Some(chat) = self.chat.as_ref().filter(|c| turn.as_deref() == Some(c.id.as_str()))
|
||||
{
|
||||
self.followup = chat_msgs::auto_answers(&chat.messages);
|
||||
}
|
||||
if let Some(f) = self.followup.take() {
|
||||
let _ = self.send(ctx, &f);
|
||||
}
|
||||
} else if busy && parked.is_some() {
|
||||
self.save();
|
||||
}
|
||||
@@ -688,7 +752,26 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the draft to the running turn: the agent reads it with its next PaperCraft tool result.
|
||||
pub fn add_to_turn(&mut self) {
|
||||
let text = self.draft.trim().to_owned();
|
||||
let Some(chat) = self.turn_chat.as_deref().filter(|_| !text.is_empty()) else { return };
|
||||
crate::mcp::steer::add(chat, &text);
|
||||
self.added.push(text);
|
||||
self.draft.clear();
|
||||
}
|
||||
|
||||
fn finish_turn(&mut self) {
|
||||
// What the agent never picked up: sent next, or back in the box after a Stop.
|
||||
let left = self.turn_chat.as_deref().map(crate::mcp::steer::take).unwrap_or_default().join("\n\n");
|
||||
if !left.is_empty() {
|
||||
if self.stopping {
|
||||
self.draft = if self.draft.is_empty() { left } else { format!("{left}\n\n{}", self.draft) };
|
||||
} else {
|
||||
self.followup = Some(left);
|
||||
}
|
||||
}
|
||||
self.added.clear();
|
||||
self.turn = None;
|
||||
self.rx = None;
|
||||
self.stopped = std::mem::take(&mut self.stopping);
|
||||
@@ -704,11 +787,14 @@ impl ChatState {
|
||||
let skill = include_str!("../../skills/papercraft/SKILL.md");
|
||||
let dir = self.project.as_deref().map(|d| d.display().to_string()).unwrap_or_default();
|
||||
let art = if self.prefs.image_provider() == crate::mcp::imagegen::Provider::Off { "" } else { ART_RULE };
|
||||
let playbook = if crate::chat::path::which("playbook").is_some() { PLAYBOOK_RULE } else { "" };
|
||||
let helper = if self.prefs.helper_command.trim().is_empty() { "" } else { HELPER_RULE };
|
||||
let auto = if self.prefs.auto { AUTO_RULE } else { "" };
|
||||
// Plugins the user turned on, one usage line each.
|
||||
let on: Vec<&str> = crate::plugins::PLUGINS.iter().filter(|p| self.prefs.plugins.iter().any(|id| id == p.id)).map(|p| p.hint).collect();
|
||||
let plugins = if on.is_empty() { String::new() } else { format!("\n\nPlugins on, placed inside write_html (prefer them to drawn placeholders: avatars for people, real icons):\n- {}", on.join("\n- ")) };
|
||||
format!(
|
||||
"{skill}\n\n{CHAT_RULES}{art}{plugins}\n\nProject context: the open project is \"{}\" (folder {dir}). Read and edit it only through the papercraft tools.",
|
||||
"{skill}\n\n{CHAT_RULES}{art}{playbook}{helper}{auto}{plugins}\n\nProject context: the open project is \"{}\" (folder {dir}). Read and edit it only through the papercraft tools.",
|
||||
self.project_name
|
||||
)
|
||||
}
|
||||
@@ -850,6 +936,39 @@ impl ChatState {
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
/// Files an issue on a worker thread; `issues[key]` follows it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn file_issue(&mut self, key: String, d: IssueDraft, repo: String) {
|
||||
self.issues.insert(key.clone(), IssueState::Sending);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let draft = crate::issues::Draft { kind: d.kind, title: d.title, body: d.body };
|
||||
let _ = tx.send(crate::issues::file(&repo, &draft));
|
||||
});
|
||||
self.issue_rx.push((key, rx));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn poll_issues(&mut self, ctx: &egui::Context) {
|
||||
use crate::issues::Filed;
|
||||
let issues = &mut self.issues;
|
||||
self.issue_rx.retain(|(key, rx)| match rx.try_recv() {
|
||||
Ok(r) => {
|
||||
issues.insert(key.clone(), match r {
|
||||
Ok(Filed::New { number, url }) => IssueState::Filed { number, url, existing: false },
|
||||
Ok(Filed::Duplicate { number, url, .. }) => IssueState::Filed { number, url, existing: true },
|
||||
Err(e) => IssueState::Failed(e),
|
||||
});
|
||||
false
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => true,
|
||||
Err(_) => false,
|
||||
});
|
||||
if !self.issue_rx.is_empty() {
|
||||
ctx.request_repaint_after(REPAINT);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the background scan's answer; starts the first scan.
|
||||
pub fn poll_detect(&mut self, ctx: &egui::Context, prefs: &super::settings::ChatPrefs) {
|
||||
if self.prefs != *prefs {
|
||||
@@ -1202,6 +1321,63 @@ fn image_offer(ui: &mut Ui, st: &mut UiState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Report a PaperCraft issue" form: kind, title, description, then Send (filed on the
|
||||
/// repository in Settings, Updates) and the link to what was filed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn issue_form(ctx: &egui::Context, st: &mut UiState) {
|
||||
let Some(draft) = st.chat.issue_form.as_mut() else { return };
|
||||
let mut close = false;
|
||||
let mut send = false;
|
||||
let state = st.chat.issues.get(FORM_ISSUE).cloned();
|
||||
egui::Modal::new(egui::Id::new("issue-form")).show(ctx, |ui| {
|
||||
ui.set_width(ISSUE_FORM_W);
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
ui.label(RichText::new("Report a PaperCraft issue").font(theme::semibold(theme::BODY + 3.0)));
|
||||
ui.label(RichText::new(format!("Goes to {}. Leave out private content.", st.settings.chat.issue_repo)).size(theme::SECONDARY).color(theme::pal(ui).secondary));
|
||||
let kinds = [("bug", "Bug"), ("docs", "Docs"), ("feature", "Feature")];
|
||||
if let Some(k) = kit::segmented(ui, &kinds.map(|(_, l)| (l, None)), kinds.iter().position(|(k, _)| *k == draft.kind).unwrap_or(0)) {
|
||||
draft.kind = kinds[k].0.into();
|
||||
}
|
||||
ui.add(egui::TextEdit::singleline(&mut draft.title).hint_text("What goes wrong, in a line").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
|
||||
ui.add(egui::TextEdit::multiline(&mut draft.body).desired_rows(7).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
|
||||
match &state {
|
||||
Some(IssueState::Sending) => {
|
||||
ui.label(RichText::new("Sending\u{2026}").color(theme::pal(ui).secondary));
|
||||
}
|
||||
Some(IssueState::Filed { number, url, existing }) => {
|
||||
ui.hyperlink_to(if *existing { format!("Already reported as #{number}") } else { format!("Filed as #{number}") }, url);
|
||||
}
|
||||
Some(IssueState::Failed(e)) => {
|
||||
ui.label(RichText::new(e).color(theme::pal(ui).danger));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let filed = matches!(state, Some(IssueState::Filed { .. }));
|
||||
if !filed {
|
||||
let ready = !draft.title.trim().is_empty() && state != Some(IssueState::Sending);
|
||||
if ui.add_enabled_ui(ready, |ui| kit::button(ui, kit::ButtonKind::Primary, None, "Send").clicked()).inner {
|
||||
send = true;
|
||||
}
|
||||
}
|
||||
if kit::button(ui, kit::ButtonKind::Secondary, None, if filed { "Done" } else { "Cancel" }).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if send {
|
||||
let d = draft.clone();
|
||||
let repo = st.settings.chat.issue_repo.clone();
|
||||
st.chat.file_issue(FORM_ISSUE.into(), d, repo);
|
||||
}
|
||||
if close {
|
||||
st.chat.issue_form = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const ISSUE_FORM_W: f32 = 520.0;
|
||||
|
||||
/// What a fresh session needs from a chat that grew long: the user's recent requests and the
|
||||
/// agent's last report. The canvas itself is read with the tools.
|
||||
fn recap(messages: &[Message]) -> String {
|
||||
@@ -1425,6 +1601,11 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let column_rect = ui.max_rect();
|
||||
chat_msgs::lightbox(&ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
st.chat.poll_issues(&ctx);
|
||||
issue_form(&ctx, st);
|
||||
}
|
||||
st.chat.ensure_chat();
|
||||
if std::mem::take(&mut st.settings.chat_changed) {
|
||||
// Paths, key or Re-scan changed in Settings: what was detected is stale.
|
||||
@@ -1478,6 +1659,7 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
error: st.chat.error.as_deref(),
|
||||
stopped: st.chat.stopped,
|
||||
budget: st.chat.budget_hit.then_some(st.settings.chat.turn_budget),
|
||||
issues: &st.chat.issues,
|
||||
signin: signin.as_ref(),
|
||||
live_run: st.run_open.iter().find(|r| r.client.split(" \u{b7} ").next() == Some(crate::mcp::CHAT_CLIENT)),
|
||||
};
|
||||
@@ -1521,6 +1703,14 @@ fn apply_acts(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, acts: Acts
|
||||
if let Some(id) = acts.revert {
|
||||
st.pending_run_revert = Some(id);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some((key, d)) = acts.send_issue {
|
||||
let repo = st.settings.chat.issue_repo.clone();
|
||||
st.chat.file_issue(key, d, repo);
|
||||
}
|
||||
if let Some(key) = acts.discard_issue {
|
||||
st.chat.issues.insert(key, IssueState::Discarded);
|
||||
}
|
||||
if let Some(id) = acts.restore {
|
||||
st.pending_restore = Some(id);
|
||||
}
|
||||
@@ -1634,6 +1824,11 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
|
||||
st.chat.start_new_chat();
|
||||
ui.close();
|
||||
}
|
||||
if ui.add(egui::Button::new(RichText::new("Report a PaperCraft issue\u{2026}").size(theme::SECONDARY + 1.0)).frame(false)).clicked() {
|
||||
st.chat.issues.remove(FORM_ISSUE);
|
||||
st.chat.issue_form = Some(IssueDraft { kind: "bug".into(), title: String::new(), body: ISSUE_TEMPLATE.into() });
|
||||
ui.close();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let current = st.chat.chat.as_ref().map(|c| c.id.clone());
|
||||
let now = now_ms();
|
||||
@@ -1747,6 +1942,15 @@ 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;
|
||||
// Messages added to the running turn: waiting for the agent's next step, or seen.
|
||||
if !st.chat.added.is_empty() {
|
||||
let waiting = st.chat.turn_chat.as_deref().map(crate::mcp::steer::pending).unwrap_or_default();
|
||||
for a in &st.chat.added {
|
||||
let (tag, color) = if waiting.contains(a) { ("Waiting", p.tertiary) } else { ("Seen", p.secondary) };
|
||||
let line: String = a.lines().next().unwrap_or_default().chars().take(ADDED_PREVIEW).collect();
|
||||
ui.label(RichText::new(format!("{tag} \u{b7} {line}")).size(theme::SECONDARY).color(color));
|
||||
}
|
||||
}
|
||||
if !st.chat.sels.is_empty() || !st.chat.images.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let clicked = st.chat.sels.iter().position(|s| sel_chip(ui, &s.label));
|
||||
@@ -1784,7 +1988,7 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
|
||||
} else if !backend_ok {
|
||||
"Connect an agent to start chatting"
|
||||
} else if running {
|
||||
"Reply, or press Stop to interrupt"
|
||||
"Add to what it is doing (Enter), or press Stop to interrupt"
|
||||
} else {
|
||||
"Ask for a design, a fix or a review"
|
||||
};
|
||||
@@ -1800,45 +2004,58 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
|
||||
.lock_focus(true),
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if kit::chip(ui, "@ Selection", false).on_hover_text("Add the selection (Cmd+L)").clicked() {
|
||||
add_selection(ed, &mut st.chat);
|
||||
// Buttons on the right; the chips wrap in what is left instead of running under them.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if running {
|
||||
let stopping = st.chat.stopping;
|
||||
let label = if stopping { "Stopping\u{2026}" } else { "Stop" };
|
||||
if ui.add_enabled_ui(!stopping, |ui| kit::button(ui, kit::ButtonKind::Danger, None, label).clicked()).inner {
|
||||
st.chat.stop();
|
||||
}
|
||||
let can = !stopping && !st.chat.draft.trim().is_empty();
|
||||
let add = ui.add_enabled_ui(can, |ui| kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::ArrowRight), "Add").clicked()).inner;
|
||||
if add {
|
||||
st.chat.add_to_turn();
|
||||
}
|
||||
} else {
|
||||
let can = backend_ok && !no_project && (!st.chat.draft.trim().is_empty() || !st.chat.images.is_empty());
|
||||
ui.add_enabled_ui(can, |ui| {
|
||||
submit = kit::button(ui, kit::ButtonKind::Primary, Some(Icon::ArrowRight), "Send").clicked();
|
||||
});
|
||||
}
|
||||
let tip = "Point at the canvas: click a layer or Shift+drag an area (Esc cancels). Option+click adds a layer too.";
|
||||
if kit::chip(ui, "Pick", st.chat.picking).on_hover_text(tip).clicked() {
|
||||
st.chat.picking = !st.chat.picking;
|
||||
}
|
||||
super::actions::chip(ui, ed, st);
|
||||
let tip = "Let the agent search the web and bring back reference images";
|
||||
if kit::chip(ui, "Web", st.settings.chat.research).on_hover_text(tip).clicked() {
|
||||
st.settings.chat.research = !st.settings.chat.research;
|
||||
st.settings.chat.save();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if theme::icon_button(ui, Icon::Image, 24.0, false).on_hover_text("Attach an image (or paste, or drop one)").clicked()
|
||||
&& let Some(path) = rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]).pick_file()
|
||||
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
&& let (Some(mime), Ok(data)) = (image_mime(&name), std::fs::read(&path))
|
||||
{
|
||||
st.chat.add_image(name, mime, data);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if running {
|
||||
let stopping = st.chat.stopping;
|
||||
let label = if stopping { "Stopping\u{2026}" } else { "Stop" };
|
||||
if ui.add_enabled_ui(!stopping, |ui| kit::button(ui, kit::ButtonKind::Danger, None, label).clicked()).inner {
|
||||
st.chat.stop();
|
||||
}
|
||||
} else {
|
||||
let can = backend_ok && !no_project && (!st.chat.draft.trim().is_empty() || !st.chat.images.is_empty());
|
||||
ui.add_enabled_ui(can, |ui| {
|
||||
submit = kit::button(ui, kit::ButtonKind::Primary, Some(Icon::ArrowRight), "Send").clicked();
|
||||
});
|
||||
ui.label(RichText::new("Enter sends \u{b7} Shift+Enter newline").size(theme::SECONDARY).color(p.tertiary));
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true), |ui| {
|
||||
if kit::chip(ui, "@ Selection", false).on_hover_text("Add the selection (Cmd+L)").clicked() {
|
||||
add_selection(ed, &mut st.chat);
|
||||
}
|
||||
let tip = "Point at the canvas: click a layer or Shift+drag an area (Esc cancels). Option+click adds a layer too.";
|
||||
if kit::chip(ui, "Pick", st.chat.picking).on_hover_text(tip).clicked() {
|
||||
st.chat.picking = !st.chat.picking;
|
||||
}
|
||||
super::actions::chip(ui, ed, st);
|
||||
let tip = "Let the agent search the web and bring back reference images";
|
||||
if kit::chip(ui, "Web", st.settings.chat.research).on_hover_text(tip).clicked() {
|
||||
st.settings.chat.research = !st.settings.chat.research;
|
||||
st.settings.chat.save();
|
||||
}
|
||||
let tip = "Keep going on its own: questions get the recommended answer and the next step starts, until you press Stop or a turn hits its budget";
|
||||
if kit::chip(ui, "Auto", st.settings.chat.auto).on_hover_text(tip).clicked() {
|
||||
st.settings.chat.auto = !st.settings.chat.auto;
|
||||
st.settings.chat.save();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if theme::icon_button(ui, Icon::Image, 24.0, false).on_hover_text("Attach an image (or paste, or drop one)").clicked()
|
||||
&& let Some(path) = rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]).pick_file()
|
||||
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
&& let (Some(mime), Ok(data)) = (image_mime(&name), std::fs::read(&path))
|
||||
{
|
||||
st.chat.add_image(name, mime, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if enter && running && !st.chat.stopping {
|
||||
st.chat.add_to_turn();
|
||||
}
|
||||
if (submit || enter) && !running && backend_ok && !no_project {
|
||||
let ctx = ui.ctx().clone();
|
||||
st.chat.sel_notes = selection_notes(ed, &st.chat.sels);
|
||||
@@ -1856,6 +2073,25 @@ pub fn mode_switch(ui: &mut Ui, chat_mode: bool) -> Option<bool> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn messages_added_during_a_turn_wait_for_the_agent_then_follow_up() {
|
||||
let mut st = ChatState { turn_chat: Some("added-test".into()), ..Default::default() };
|
||||
st.draft = "use the amber badge".into();
|
||||
st.add_to_turn();
|
||||
assert!(st.draft.is_empty());
|
||||
assert_eq!(crate::mcp::steer::pending("added-test"), vec!["use the amber badge".to_string()]);
|
||||
st.finish_turn();
|
||||
assert_eq!(st.followup.take().as_deref(), Some("use the amber badge"), "not picked up: sent next");
|
||||
assert!(st.added.is_empty());
|
||||
// After Stop it goes back into the box instead.
|
||||
st.turn_chat = Some("added-test".into());
|
||||
st.draft = "and a dark mode".into();
|
||||
st.add_to_turn();
|
||||
st.stopping = true;
|
||||
st.finish_turn();
|
||||
assert_eq!((st.draft.as_str(), st.followup.is_none()), ("and a dark mode", true));
|
||||
}
|
||||
use crate::chat::claude_code::parse_line_partial;
|
||||
|
||||
const TOOL: &str = include_str!("../../tests/fixtures/chat/claude_code_tool.jsonl");
|
||||
|
||||
@@ -19,6 +19,9 @@ const LIGHTBOX_MAX: u32 = 2048;
|
||||
/// The tool whose calls are drawn as a question card instead of a tool card.
|
||||
const ASK_TOOL: &str = "ask_user";
|
||||
const REFS_TOOL: &str = "fetch_reference_images";
|
||||
const ISSUE_TOOL: &str = "report_issue";
|
||||
/// Lines of an issue draft's body shown on its card.
|
||||
const ISSUE_PREVIEW_LINES: usize = 6;
|
||||
const GEN_TOOL: &str = "generate_image";
|
||||
const REF_THUMB_H: f32 = 96.0;
|
||||
/// Opacity of a fetched image the agent did not keep.
|
||||
@@ -62,6 +65,9 @@ pub struct Acts {
|
||||
pub revert: Option<String>,
|
||||
pub accept: Option<String>,
|
||||
pub approve: Option<(String, bool)>,
|
||||
/// File this issue draft (its key and contents).
|
||||
pub send_issue: Option<(String, super::chat::IssueDraft)>,
|
||||
pub discard_issue: Option<String>,
|
||||
}
|
||||
|
||||
pub struct View<'a> {
|
||||
@@ -75,6 +81,8 @@ pub struct View<'a> {
|
||||
pub stopped: bool,
|
||||
/// The last turn was stopped by the turn budget: the budget, in tokens read.
|
||||
pub budget: Option<u64>,
|
||||
/// Issue drafts being filed or filed, by `report_issue` call id.
|
||||
pub issues: &'a std::collections::HashMap<String, super::chat::IssueState>,
|
||||
pub signin: Option<&'a super::chat::chat_agents::SignIn>,
|
||||
/// The chat's run while it is being recorded, for the live count on the step group.
|
||||
pub live_run: Option<&'a Run>,
|
||||
@@ -892,7 +900,8 @@ fn assistant_segment(ui: &mut Ui, md_id: Id, m: &Message, v: &View, offered: &[S
|
||||
}
|
||||
// A call waiting for approval is shown by its approval card instead.
|
||||
let waiting = |t: &ToolCall| v.approvals.iter().any(|a| a.id == t.id);
|
||||
let tools: Vec<ToolCall> = m.tools.iter().filter(|t| !is_question(t)).cloned().collect();
|
||||
let issues: Vec<&ToolCall> = m.tools.iter().filter(|t| t.name == ISSUE_TOOL && t.error.is_none()).collect();
|
||||
let tools: Vec<ToolCall> = m.tools.iter().filter(|t| !is_question(t) && !(t.name == ISSUE_TOOL && t.error.is_none())).cloned().collect();
|
||||
let (refs, tools): (Vec<ToolCall>, Vec<ToolCall>) = tools.into_iter().partition(|t| [REFS_TOOL, GEN_TOOL].contains(&t.name.as_str()) && t.result.is_some());
|
||||
// Finished calls stay folded in their group while the turn runs and only the live ones show
|
||||
// as cards, so the list does not grow and shrink (and the scroll jump) on every call.
|
||||
@@ -911,6 +920,54 @@ fn assistant_segment(ui: &mut Ui, md_id: Id, m: &Message, v: &View, offered: &[S
|
||||
for t in &refs {
|
||||
reference_gallery(ui, t, offered, acts);
|
||||
}
|
||||
for t in issues {
|
||||
issue_card(ui, t, v.issues.get(&t.id), acts);
|
||||
}
|
||||
}
|
||||
|
||||
/// An issue the agent drafted about PaperCraft: what it says, and Send or Discard.
|
||||
fn issue_card(ui: &mut Ui, t: &ToolCall, state: Option<&super::chat::IssueState>, acts: &mut Acts) {
|
||||
use super::chat::IssueState;
|
||||
let p = theme::pal(ui);
|
||||
let s = |k: &str| t.args[k].as_str().unwrap_or_default().to_owned();
|
||||
let draft = super::chat::IssueDraft { kind: s("kind"), title: s("title"), body: s("body") };
|
||||
card(ui).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
theme::paint_icon(ui.painter(), r, Icon::Note, theme::accent());
|
||||
ui.label(RichText::new("Issue for PaperCraft").font(theme::semibold(theme::BODY)));
|
||||
kit::chip_ink(ui, &draft.kind, None);
|
||||
});
|
||||
ui.label(RichText::new(&draft.title).font(theme::semibold(theme::BODY)));
|
||||
ui.add(egui::Label::new(RichText::new(draft.body.lines().take(ISSUE_PREVIEW_LINES).collect::<Vec<_>>().join("\n")).size(theme::SECONDARY).color(p.secondary)).wrap());
|
||||
match state {
|
||||
Some(IssueState::Sending) => {
|
||||
ui.horizontal(|ui| {
|
||||
spinner(ui, 12.0, theme::accent());
|
||||
muted(ui, "Sending\u{2026}");
|
||||
});
|
||||
}
|
||||
Some(IssueState::Filed { number, url, existing }) => {
|
||||
ui.hyperlink_to(if *existing { format!("Already reported as #{number}") } else { format!("Filed as #{number}") }, url);
|
||||
}
|
||||
Some(IssueState::Discarded) => muted(ui, "Discarded"),
|
||||
failed => {
|
||||
if let Some(IssueState::Failed(e)) = failed {
|
||||
ui.label(RichText::new(e).size(theme::SECONDARY).color(p.danger));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if kit::button(ui, kit::ButtonKind::Primary, None, if failed.is_some() { "Try again" } else { "Send issue" }).clicked() {
|
||||
acts.send_issue = Some((t.id.clone(), draft.clone()));
|
||||
}
|
||||
if kit::chip(ui, "Discard", false).clicked() {
|
||||
acts.discard_issue = Some(t.id.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// An `ask_user` call that went through: drawn as a question card, not a tool card.
|
||||
@@ -1040,6 +1097,16 @@ fn question_card(ui: &mut Ui, t: &ToolCall, open: bool, acts: &mut Acts) {
|
||||
ui.data_mut(|d| d.insert_temp(id, picks));
|
||||
}
|
||||
|
||||
/// Auto mode's reply when the chat's last message ends on questions: every question answered
|
||||
/// with its first (recommended) option.
|
||||
pub(crate) fn auto_answers(messages: &[crate::chat::Message]) -> Option<String> {
|
||||
let last = messages.last().filter(|m| m.role == crate::chat::Role::Assistant)?;
|
||||
let t = last.tools.iter().rev().find(|t| is_question(t))?;
|
||||
let qs = t.args["questions"].as_array()?;
|
||||
let picks: Vec<Vec<usize>> = qs.iter().map(|q| if q["options"].as_array().is_some_and(|o| !o.is_empty()) { vec![0] } else { vec![] }).collect();
|
||||
Some(format!("{}\n\n(Auto mode: your recommended answers were taken. Go on.)", answers_text(qs, &picks)))
|
||||
}
|
||||
|
||||
/// "Answers:" then one line per question; one left unpicked is the agent's call.
|
||||
fn answers_text(qs: &[Value], picks: &[Vec<usize>]) -> String {
|
||||
let lines = qs.iter().zip(picks).map(|(q, pick)| {
|
||||
@@ -1417,6 +1484,20 @@ mod tests {
|
||||
assert_eq!(tool_state(&t, true, &[]), ToolState::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_mode_takes_the_first_option_of_the_last_questions() {
|
||||
use crate::chat::{Message, Role, ToolCall};
|
||||
let ask = ToolCall { id: "a".into(), name: ASK_TOOL.into(), args: serde_json::json!({"questions": [
|
||||
{"question": "Density?", "options": [{"label": "Compact"}, {"label": "Touch"}]},
|
||||
{"question": "Next?", "options": [{"label": "Mobile layout"}]}]}), ..Default::default() };
|
||||
let mut msgs = vec![Message { role: Role::Assistant, tools: vec![ask], ..Default::default() }];
|
||||
let a = auto_answers(&msgs).unwrap();
|
||||
assert!(a.contains("Density? Compact") && a.contains("Next? Mobile layout"), "{a}");
|
||||
msgs.push(Message { role: Role::User, text: "hi".into(), ..Default::default() });
|
||||
assert_eq!(auto_answers(&msgs), None, "already answered");
|
||||
assert_eq!(auto_answers(&[Message { role: Role::Assistant, text: "done".into(), ..Default::default() }]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_name_each_pick_and_leave_the_rest_to_the_agent() {
|
||||
let qs = [
|
||||
|
||||
@@ -927,6 +927,18 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
st.code_target = v.into();
|
||||
}
|
||||
});
|
||||
// A component linked to a library (`set_code_link`, `apply_ui_kit`) is written as its code.
|
||||
if let Some(link) = ed.doc.get(root_id).and_then(|n| crate::mcp::codegen::code_link(&ed.doc, n)) {
|
||||
let egui = st.code_target == "egui";
|
||||
let usage = if egui { &link.egui } else { &link.react };
|
||||
row(ui, "Library", |ui| {
|
||||
ui.label(RichText::new(if usage.is_empty() { format!("{} (no {} code)", link.kit, st.code_target) } else { link.kit.clone() }).size(theme::SECONDARY));
|
||||
});
|
||||
if !usage.is_empty() {
|
||||
ui.add(egui::Label::new(RichText::new(usage.lines().next().unwrap_or(usage)).monospace().size(theme::SECONDARY)).truncate())
|
||||
.on_hover_text(if egui { usage.clone() } else { format!("{}\n{usage}", link.import) });
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let w = half(ui);
|
||||
if ui
|
||||
|
||||
@@ -609,21 +609,6 @@ pub fn history_button(ui: &mut Ui, redo: bool, enabled: bool) -> Response {
|
||||
resp.unwrap_or_else(|| ui.label(""))
|
||||
}
|
||||
|
||||
/// Play triangle button (Present).
|
||||
pub fn play_button(ui: &mut Ui, tip: &str) -> Response {
|
||||
draw_button(ui, tip, 26.0, true, |ui, rect, tint| {
|
||||
let c = rect.center();
|
||||
let r = rect.width() * 0.46;
|
||||
let pts = vec![
|
||||
c + vec2(-r * 0.6, -r),
|
||||
c + vec2(r, 0.0),
|
||||
c + vec2(-r * 0.6, r),
|
||||
];
|
||||
ui.painter()
|
||||
.add(egui::Shape::closed_line(pts, Stroke::new(1.6, tint)));
|
||||
})
|
||||
}
|
||||
|
||||
/// Height of [`segmented`].
|
||||
pub const SEGMENTED_H: f32 = 28.0;
|
||||
|
||||
|
||||
@@ -436,7 +436,7 @@ impl UiState {
|
||||
}
|
||||
|
||||
/// A pill with three bouncing dots and what the agent is doing; nothing when it is idle.
|
||||
fn agent_pill(ui: &mut Ui, st: &UiState, with_text: bool) {
|
||||
fn agent_pill(ui: &mut Ui, st: &UiState, with_text: bool, keep_free: f32) {
|
||||
let Some(verb) = st.agent_working() else {
|
||||
return;
|
||||
};
|
||||
@@ -460,16 +460,15 @@ fn agent_pill(ui: &mut Ui, st: &UiState, with_text: bool) {
|
||||
ui.painter()
|
||||
.circle_filled(egui::pos2(x, r.center().y - hop), 2.5, accent);
|
||||
}
|
||||
if with_text {
|
||||
let text = match run_progress(&st.run_open) {
|
||||
Some(n) => format!("{verb} \u{b7} {n}"),
|
||||
None => format!("{verb}…"),
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(text)
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.text),
|
||||
);
|
||||
let text = match run_progress(&st.run_open) {
|
||||
Some(n) => format!("{verb} \u{b7} {n}"),
|
||||
None => format!("{verb}…"),
|
||||
};
|
||||
let text = RichText::new(text).size(theme::SECONDARY).color(p.text);
|
||||
// Only the dots when the text would run under the buttons on the right.
|
||||
let w = egui::WidgetText::from(text.clone()).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
if with_text && ui.available_width() - keep_free > w + AGENT_PILL_PAD {
|
||||
ui.label(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -519,13 +518,14 @@ fn agent_glow(ctx: &egui::Context, st: &UiState, canvas: egui::Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The agent pill's own padding and gap, kept clear beside its text.
|
||||
const AGENT_PILL_PAD: f32 = 32.0;
|
||||
|
||||
/// Gap between the floating panels and around the canvas.
|
||||
pub(crate) const PANEL_GAP: f32 = 12.0;
|
||||
|
||||
/// Canvas width under which the top bar drops the zoom pill (it is also on the keyboard).
|
||||
const TIGHT_BAR_W: f32 = 520.0;
|
||||
/// Room the Design | Chat switch takes in the top bar.
|
||||
const CHAT_SWITCH_W: f32 = 140.0;
|
||||
/// Tallest the top bar's page menu grows before it scrolls.
|
||||
const PAGE_MENU_MAX_H: f32 = 360.0;
|
||||
|
||||
@@ -1046,22 +1046,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
// The title column sits at the top of the bar while the bar centres single widgets, so
|
||||
// place the home icon on the column's centre (measured last frame) instead.
|
||||
let column_id = egui::Id::new("title-column-centre");
|
||||
let cy = ui
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<f32>(column_id))
|
||||
.unwrap_or(ui.max_rect().center().y);
|
||||
let slot = egui::Rect::from_center_size(
|
||||
egui::pos2(ui.cursor().left() + 13.0, cy),
|
||||
egui::vec2(26.0, 26.0),
|
||||
);
|
||||
let home = ui
|
||||
.scope_builder(egui::UiBuilder::new().max_rect(slot), |ui| {
|
||||
theme::icon_button(ui, theme::Icon::Grid, 26.0, false)
|
||||
})
|
||||
.inner;
|
||||
let home = theme::icon_button(ui, theme::Icon::Grid, 26.0, false);
|
||||
if home.on_hover_text("All projects").clicked() {
|
||||
ed.requests.push(crate::editor::AppRequest::Home);
|
||||
}
|
||||
@@ -1075,7 +1060,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
} else {
|
||||
st.project_name.clone()
|
||||
};
|
||||
let column = ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// Leave room for the zoom pill and icons on the right; long titles truncate.
|
||||
ui.set_max_width(
|
||||
(ui.available_width()
|
||||
@@ -1085,11 +1070,9 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
300.0
|
||||
} else {
|
||||
420.0
|
||||
}
|
||||
- CHAT_SWITCH_W)
|
||||
})
|
||||
.max(40.0),
|
||||
);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let state = if !st.save_state.is_empty() {
|
||||
st.save_state.clone()
|
||||
} else if ed.is_dirty() {
|
||||
@@ -1097,56 +1080,51 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// The text, not its row: the row is button height with the title low in it.
|
||||
let top = ui
|
||||
.horizontal(|ui| {
|
||||
let t = ui.add(egui::Label::new(title_text(title)).truncate());
|
||||
if !state.is_empty() {
|
||||
kit::chip(ui, &state, state == "Saved");
|
||||
}
|
||||
t.rect.top()
|
||||
})
|
||||
.inner;
|
||||
// Title and page share one row: two rows outgrow the bar and spill onto the canvas.
|
||||
// The page name opens a page menu: Chat mode has no Pages panel to switch with.
|
||||
let many = ed.pages.len() > 1;
|
||||
let row = ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let label = ui.add(
|
||||
egui::Label::new(RichText::new(ed.page_name()).size(theme::SECONDARY).color(p.secondary))
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if many {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
|
||||
theme::paint_icon(ui.painter(), r, Icon::ChevronDown, p.secondary);
|
||||
}
|
||||
label
|
||||
});
|
||||
let page = row.inner;
|
||||
let bottom = page.rect.bottom();
|
||||
if many {
|
||||
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
egui::Popup::menu(&page).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
|
||||
for i in 0..ed.pages.len() {
|
||||
let name = ed.pages[i].name.clone();
|
||||
if ui.selectable_label(i == ed.active_page, name).clicked() {
|
||||
ed.switch_page(i);
|
||||
ui.close();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Label::new(title_text(title)).truncate());
|
||||
ui.label(RichText::new("/").size(theme::SECONDARY).color(p.secondary));
|
||||
let page = ui
|
||||
.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let label = ui.add(
|
||||
egui::Label::new(RichText::new(ed.page_name()).size(theme::SECONDARY).color(p.secondary))
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if many {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
|
||||
theme::paint_icon(ui.painter(), r, Icon::ChevronDown, p.secondary);
|
||||
}
|
||||
label
|
||||
})
|
||||
.inner;
|
||||
if many {
|
||||
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
egui::Popup::menu(&page).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
|
||||
for i in 0..ed.pages.len() {
|
||||
let name = ed.pages[i].name.clone();
|
||||
if ui.selectable_label(i == ed.active_page, name).clicked() {
|
||||
ed.switch_page(i);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
(top + bottom) / 2.0
|
||||
}
|
||||
if !state.is_empty() {
|
||||
kit::chip(ui, &state, state == "Saved");
|
||||
}
|
||||
});
|
||||
});
|
||||
let centre = column.inner;
|
||||
if (centre - cy).abs() > 0.5 {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(column_id, centre));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
agent_pill(ui, st, !tight);
|
||||
// The buttons on the right, as wide as last frame: the agent pill leaves them that room.
|
||||
let right_id = egui::Id::new("top-bar-right-width");
|
||||
let right_w = ui.ctx().data(|d| d.get_temp::<f32>(right_id)).unwrap_or(0.0);
|
||||
agent_pill(ui, st, !tight, right_w);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let share = kit::button(ui, kit::ButtonKind::Primary, Some(Icon::Export), "Share");
|
||||
@@ -1180,7 +1158,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
{
|
||||
st.updater.open = true;
|
||||
}
|
||||
if kit::play_button(ui, "Present").clicked() {
|
||||
if theme::icon_button(ui, theme::Icon::Play, 26.0, false).on_hover_text("Present").clicked() {
|
||||
start_present(ed, st);
|
||||
}
|
||||
if st.chat_mode
|
||||
@@ -1248,6 +1226,8 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
});
|
||||
});
|
||||
}
|
||||
let w = ui.min_rect().width();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(right_id, w));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn modal_height(ctx: &egui::Context) -> f32 {
|
||||
}
|
||||
const DEFAULT_MODEL: &str = "claude-opus-5-5";
|
||||
const DEFAULT_EFFORT: &str = "medium";
|
||||
const ISSUE_REPO: &str = "https://git.marcospaulo.dev.br/masi/papercraft";
|
||||
/// Generous: a long build (eight screens) read about 5M tokens.
|
||||
const DEFAULT_TURN_BUDGET: u64 = 10_000_000;
|
||||
pub const TURN_BUDGETS: [(u64, &str); 5] = [(0, "No limit"), (2_000_000, "2M tokens"), (5_000_000, "5M tokens"), (10_000_000, "10M tokens"), (20_000_000, "20M tokens")];
|
||||
@@ -110,6 +111,13 @@ pub struct ChatPrefs {
|
||||
pub image_offer_seen: bool,
|
||||
/// Plugins turned on (`plugins::PLUGINS` ids); all off by default.
|
||||
pub plugins: Vec<String>,
|
||||
/// Where PaperCraft's own issues go (the form and the agent's drafts).
|
||||
pub issue_repo: String,
|
||||
/// The helper agent `delegate` runs (`{prompt}`); empty: no helper, the chat agent does it all.
|
||||
pub helper_command: String,
|
||||
/// Auto mode (the composer's Auto chip): a turn that ends on questions is answered with the
|
||||
/// recommended options and the chat goes on, until Stop or the turn budget.
|
||||
pub auto: bool,
|
||||
}
|
||||
|
||||
impl Default for ChatPrefs {
|
||||
@@ -132,6 +140,9 @@ impl Default for ChatPrefs {
|
||||
image_command: String::new(),
|
||||
image_offer_seen: false,
|
||||
plugins: Vec::new(),
|
||||
issue_repo: ISSUE_REPO.into(),
|
||||
helper_command: String::new(),
|
||||
auto: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,7 +213,7 @@ impl ChatPrefs {
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return dirs::config_dir().map(|d| d.join("papercraft").join("chat_settings.json"));
|
||||
return crate::project::config_dir().map(|d| d.join("chat_settings.json"));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
@@ -231,6 +242,9 @@ pub struct SettingsUi {
|
||||
pub chat_changed: bool,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
chat_page: native::ChatPage,
|
||||
/// A token typed on the Updates page, until saved to the keychain.
|
||||
issue_token: String,
|
||||
issue_note: Option<String>,
|
||||
}
|
||||
|
||||
impl SettingsUi {
|
||||
@@ -250,6 +264,8 @@ impl Default for SettingsUi {
|
||||
chat_changed: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
chat_page: native::ChatPage::default(),
|
||||
issue_token: String::new(),
|
||||
issue_note: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -496,6 +512,34 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
|
||||
ui.label("Reload the page to get the latest version.");
|
||||
},
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
ui.add_space(16.0);
|
||||
kit::section_header(ui, "Issues", None);
|
||||
ui.label(RichText::new("Where \"Report a PaperCraft issue\" and the agent's issue drafts go. Nothing is sent without your Send.").color(theme::pal(ui).secondary));
|
||||
ui.add_space(6.0);
|
||||
let s = &mut st.settings;
|
||||
kit::setting_row(ui, "Repository", "Gitea or GitHub repository URL.", |ui| {
|
||||
if ui.add(egui::TextEdit::singleline(&mut s.chat.issue_repo).desired_width(320.0).margin(theme::INPUT_MARGIN)).lost_focus() {
|
||||
s.chat.save();
|
||||
}
|
||||
});
|
||||
let saved = crate::issues::token().is_some();
|
||||
let desc = if saved { "Saved in the keychain." } else { "Optional: without one, your tea login for this server is used." };
|
||||
kit::setting_row(ui, "Access token", desc, |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut s.issue_token).password(true).hint_text(if saved { "•••• saved" } else { "token" }).desired_width(200.0).margin(theme::INPUT_MARGIN));
|
||||
if kit::button(ui, kit::ButtonKind::Secondary, None, if s.issue_token.is_empty() && saved { "Remove" } else { "Save" }).clicked() {
|
||||
s.issue_note = Some(match crate::issues::set_token(&s.issue_token) {
|
||||
Ok(()) => if s.issue_token.is_empty() { "Token removed".into() } else { "Token saved".into() },
|
||||
Err(e) => e,
|
||||
});
|
||||
s.issue_token.clear();
|
||||
}
|
||||
});
|
||||
if let Some(n) = &st.settings.issue_note {
|
||||
ui.label(RichText::new(n).size(theme::SECONDARY).color(theme::pal(ui).secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1179,6 +1223,37 @@ mod native {
|
||||
note(ui, "Lets the agent make card art, heroes and reference images. Off by default.");
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
field_row(ui, "Helper", |ui| {
|
||||
use crate::mcp::delegate::{OPENCODE_COMMAND, PI_COMMAND};
|
||||
let width = ui.available_width();
|
||||
let cmd = prefs.helper_command.trim();
|
||||
let current = if cmd.is_empty() {
|
||||
""
|
||||
} else if cmd.starts_with("pi ") {
|
||||
"pi"
|
||||
} else if cmd.starts_with("opencode ") {
|
||||
"opencode"
|
||||
} else {
|
||||
"command"
|
||||
};
|
||||
let options = [("", "Off"), ("pi", "pi"), ("opencode", "opencode"), ("command", "Custom command")];
|
||||
if let Some(v) = kit::select(ui, current, &options, width) {
|
||||
prefs.helper_command = match v {
|
||||
"pi" => PI_COMMAND.into(),
|
||||
"opencode" => OPENCODE_COMMAND.into(),
|
||||
"command" => "my-agent {prompt}".into(),
|
||||
_ => String::new(),
|
||||
};
|
||||
ed.save = true;
|
||||
}
|
||||
if current.is_empty() {
|
||||
note(ui, "A cheaper agent the chat agent hands review, reading and simple builds to. Off by default.");
|
||||
} else {
|
||||
text_edit(ui, &mut prefs.helper_command, "my-agent {prompt}", ed);
|
||||
note(ui, "Any agent CLI with a shell that takes {prompt} and prints its answer; it drives PaperCraft with papercraft-cli. Change --model / -m to pick its model (opencode with ollama/... runs locally).");
|
||||
}
|
||||
});
|
||||
field_row(ui, "Turn budget", |ui| {
|
||||
let width = ui.available_width();
|
||||
if let Some(b) = kit::select(ui, prefs.turn_budget, &TURN_BUDGETS, width) {
|
||||
@@ -1237,6 +1312,9 @@ mod tests {
|
||||
image_command: "my-tool {prompt} {out}".into(),
|
||||
image_offer_seen: true,
|
||||
plugins: vec!["avataaars".into()],
|
||||
issue_repo: "https://example.com/me/repo".into(),
|
||||
helper_command: "pi -p {prompt}".into(),
|
||||
auto: true,
|
||||
};
|
||||
assert_eq!(ChatPrefs::from_json(&prefs.to_json()), prefs);
|
||||
assert_eq!(ChatPrefs::from_json(&ChatPrefs::default().to_json()), ChatPrefs::default());
|
||||
@@ -1332,7 +1410,7 @@ mod tests {
|
||||
open: ["agent:claude-code", "agent:qwen", "agent:mine", "ep:anthropic", "ep:ep-1"].into_iter().map(String::from).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
st.settings = SettingsUi { open: true, page: Page::Chat, chat, chat_changed: false, chat_page: page };
|
||||
st.settings = SettingsUi { open: true, page: Page::Chat, chat, chat_changed: false, chat_page: page, issue_token: String::new(), issue_note: None };
|
||||
let mut ed = crate::editor::Editor::default();
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! UI kits: a library's look and its code, as data. Each kit has its tokens (colors in light and
|
||||
//! dark, radius, control height, type size) and components drawn in its style and density, each
|
||||
//! with a `CodeLink` so the code export and the AI spec use the library component instead of
|
||||
//! rebuilding it (`apply_ui_kit` builds a page from a kit; `specs/ui-kits/spec.md`).
|
||||
//!
|
||||
//! In templates `{Name}` is the text of the instance's layer named `Name` (see `layer-name` in the
|
||||
//! markup). Web kits also carry an egui template for the components egui has, so one design can
|
||||
//! be handed off to either.
|
||||
|
||||
pub struct Kit {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub about: &'static str,
|
||||
pub light: &'static [(&'static str, &'static str)],
|
||||
pub dark: &'static [(&'static str, &'static str)],
|
||||
pub tokens: &'static [(&'static str, &'static str)],
|
||||
/// The kit page's background variable.
|
||||
pub surface: &'static str,
|
||||
pub components: &'static [KitComponent],
|
||||
}
|
||||
|
||||
pub struct KitComponent {
|
||||
pub name: &'static str,
|
||||
pub html: &'static str,
|
||||
pub import: &'static str,
|
||||
pub react: &'static str,
|
||||
pub egui: &'static str,
|
||||
}
|
||||
|
||||
const EGUI_BUTTON: &str = "if ui.button({Label}).clicked() {\n // TODO: action\n}";
|
||||
const EGUI_INPUT: &str = "ui.add(egui::TextEdit::singleline(&mut text).hint_text({Placeholder}));";
|
||||
|
||||
pub const KITS: [Kit; 4] = [
|
||||
Kit {
|
||||
id: "shadcn",
|
||||
name: "shadcn/ui",
|
||||
about: "Neutral, quiet components on Radix and Tailwind (new-york style): 36px controls, 14px text.",
|
||||
light: &[
|
||||
("background", "#ffffff"), ("foreground", "#09090b"), ("card", "#ffffff"), ("primary", "#18181b"),
|
||||
("primary-foreground", "#fafafa"), ("secondary", "#f4f4f5"), ("secondary-foreground", "#18181b"),
|
||||
("muted", "#f4f4f5"), ("muted-foreground", "#71717a"), ("border", "#e4e4e7"), ("input", "#e4e4e7"),
|
||||
("destructive", "#dc2626"), ("ring", "#a1a1aa"),
|
||||
],
|
||||
dark: &[
|
||||
("background", "#09090b"), ("foreground", "#fafafa"), ("card", "#18181b"), ("primary", "#fafafa"),
|
||||
("primary-foreground", "#18181b"), ("secondary", "#27272a"), ("secondary-foreground", "#fafafa"),
|
||||
("muted", "#27272a"), ("muted-foreground", "#a1a1aa"), ("border", "#27272a"), ("input", "#3f3f46"),
|
||||
("destructive", "#ef4444"), ("ring", "#52525b"),
|
||||
],
|
||||
tokens: &[("radius", "10px"), ("control-height", "36px"), ("text-sm", "14px"), ("space-4", "16px"), ("space-6", "24px")],
|
||||
surface: "background",
|
||||
components: &[
|
||||
KitComponent {
|
||||
name: "Button / Default",
|
||||
html: r#"<div layer-name="Button / Default" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--primary)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--primary-foreground)">Button</span></div>"#,
|
||||
import: r#"import { Button } from "@/components/ui/button""#,
|
||||
react: "<Button>{Label}</Button>",
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Outline",
|
||||
html: r#"<div layer-name="Button / Outline" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--background);border:1px solid var(--border)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--foreground)">Button</span></div>"#,
|
||||
import: r#"import { Button } from "@/components/ui/button""#,
|
||||
react: r#"<Button variant="outline">{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Secondary",
|
||||
html: r#"<div layer-name="Button / Secondary" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--secondary)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--secondary-foreground)">Button</span></div>"#,
|
||||
import: r#"import { Button } from "@/components/ui/button""#,
|
||||
react: r#"<Button variant="secondary">{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Ghost",
|
||||
html: r#"<div layer-name="Button / Ghost" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--foreground)">Button</span></div>"#,
|
||||
import: r#"import { Button } from "@/components/ui/button""#,
|
||||
react: r#"<Button variant="ghost">{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Destructive",
|
||||
html: r#"<div layer-name="Button / Destructive" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--destructive)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:#ffffff">Delete</span></div>"#,
|
||||
import: r#"import { Button } from "@/components/ui/button""#,
|
||||
react: r#"<Button variant="destructive">{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Input",
|
||||
html: r#"<div layer-name="Input" style="display:flex;align-items:center;width:240px;height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--input);background:var(--background)"><span layer-name="Placeholder" style="font-size:14px;color:var(--muted-foreground)">Email</span></div>"#,
|
||||
import: r#"import { Input } from "@/components/ui/input""#,
|
||||
react: r#"<Input placeholder="{Placeholder}" />"#,
|
||||
egui: EGUI_INPUT,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Badge",
|
||||
html: r#"<div layer-name="Badge" style="display:flex;align-items:center;padding:2px 8px;border-radius:6px;background:var(--primary)"><span layer-name="Label" style="font-size:12px;font-weight:500;color:var(--primary-foreground)">Badge</span></div>"#,
|
||||
import: r#"import { Badge } from "@/components/ui/badge""#,
|
||||
react: "<Badge>{Label}</Badge>",
|
||||
egui: "ui.label(egui::RichText::new({Label}).small().strong());",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Card",
|
||||
html: r#"<div layer-name="Card" style="display:flex;flex-direction:column;gap:6px;width:320px;padding:24px;border-radius:12px;border:1px solid var(--border);background:var(--card)"><span layer-name="Title" style="font-size:16px;font-weight:600;color:var(--foreground)">Card title</span><span layer-name="Description" style="font-size:14px;color:var(--muted-foreground)">A short description of what this card holds.</span></div>"#,
|
||||
import: r#"import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card""#,
|
||||
react: "<Card>\n <CardHeader>\n <CardTitle>{Title}</CardTitle>\n <CardDescription>{Description}</CardDescription>\n </CardHeader>\n</Card>",
|
||||
egui: "egui::Frame::group(ui.style()).show(ui, |ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Switch",
|
||||
html: r#"<div layer-name="Switch" style="display:flex;align-items:center;justify-content:flex-end;width:32px;height:18px;padding:2px;border-radius:9px;background:var(--primary)"><div style="width:14px;height:14px;border-radius:7px;background:var(--primary-foreground)"></div></div>"#,
|
||||
import: r#"import { Switch } from "@/components/ui/switch""#,
|
||||
react: "<Switch defaultChecked />",
|
||||
egui: "ui.toggle_value(&mut on, \"\");",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Alert",
|
||||
html: r#"<div layer-name="Alert" style="display:flex;flex-direction:column;gap:4px;width:360px;padding:12px 16px;border-radius:10px;border:1px solid var(--border);background:var(--background)"><span layer-name="Title" style="font-size:14px;font-weight:500;color:var(--foreground)">Heads up</span><span layer-name="Description" style="font-size:14px;color:var(--muted-foreground)">You can add components to your app using the CLI.</span></div>"#,
|
||||
import: r#"import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert""#,
|
||||
react: "<Alert>\n <AlertTitle>{Title}</AlertTitle>\n <AlertDescription>{Description}</AlertDescription>\n</Alert>",
|
||||
egui: "ui.group(|ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
|
||||
},
|
||||
],
|
||||
},
|
||||
Kit {
|
||||
id: "antd",
|
||||
name: "Ant Design",
|
||||
about: "Enterprise components (antd v5): blue primary, 32px controls, 6px radius, dense tables and forms.",
|
||||
light: &[
|
||||
("background", "#f5f5f5"), ("container", "#ffffff"), ("text", "#1f1f1f"), ("text-secondary", "#737373"),
|
||||
("primary", "#1677ff"), ("border", "#d9d9d9"), ("success", "#52c41a"), ("warning", "#faad14"),
|
||||
("error", "#ff4d4f"), ("info-bg", "#e6f4ff"), ("info-border", "#91caff"),
|
||||
],
|
||||
dark: &[
|
||||
("background", "#000000"), ("container", "#141414"), ("text", "#e6e6e6"), ("text-secondary", "#8c8c8c"),
|
||||
("primary", "#1668dc"), ("border", "#424242"), ("success", "#49aa19"), ("warning", "#d89614"),
|
||||
("error", "#dc4446"), ("info-bg", "#111a2c"), ("info-border", "#15325b"),
|
||||
],
|
||||
tokens: &[("radius", "6px"), ("control-height", "32px"), ("font-size", "14px"), ("padding", "16px"), ("padding-lg", "24px")],
|
||||
surface: "background",
|
||||
components: &[
|
||||
KitComponent {
|
||||
name: "Button / Primary",
|
||||
html: r#"<div layer-name="Button / Primary" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--primary)"><span layer-name="Label" style="font-size:14px;color:#ffffff">Primary Button</span></div>"#,
|
||||
import: r#"import { Button } from "antd""#,
|
||||
react: r#"<Button type="primary">{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Default",
|
||||
html: r#"<div layer-name="Button / Default" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--container);border:1px solid var(--border)"><span layer-name="Label" style="font-size:14px;color:var(--text)">Default Button</span></div>"#,
|
||||
import: r#"import { Button } from "antd""#,
|
||||
react: "<Button>{Label}</Button>",
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Dashed",
|
||||
html: r#"<div layer-name="Button / Dashed" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--container);border:1px dashed var(--border)"><span layer-name="Label" style="font-size:14px;color:var(--text)">Dashed Button</span></div>"#,
|
||||
import: r#"import { Button } from "antd""#,
|
||||
react: r#"<Button type="dashed">{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Button / Danger",
|
||||
html: r#"<div layer-name="Button / Danger" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--error)"><span layer-name="Label" style="font-size:14px;color:#ffffff">Delete</span></div>"#,
|
||||
import: r#"import { Button } from "antd""#,
|
||||
react: r#"<Button type="primary" danger>{Label}</Button>"#,
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Input",
|
||||
html: r#"<div layer-name="Input" style="display:flex;align-items:center;width:240px;height:32px;padding:0 11px;border-radius:6px;border:1px solid var(--border);background:var(--container)"><span layer-name="Placeholder" style="font-size:14px;color:var(--text-secondary)">Basic usage</span></div>"#,
|
||||
import: r#"import { Input } from "antd""#,
|
||||
react: r#"<Input placeholder="{Placeholder}" />"#,
|
||||
egui: EGUI_INPUT,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Tag",
|
||||
html: r#"<div layer-name="Tag" style="display:flex;align-items:center;padding:0 7px;height:22px;border-radius:4px;background:var(--info-bg);border:1px solid var(--info-border)"><span layer-name="Label" style="font-size:12px;color:var(--primary)">blue</span></div>"#,
|
||||
import: r#"import { Tag } from "antd""#,
|
||||
react: r#"<Tag color="blue">{Label}</Tag>"#,
|
||||
egui: "ui.label(egui::RichText::new({Label}).small());",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Card",
|
||||
html: r#"<div layer-name="Card" style="display:flex;flex-direction:column;width:300px;border-radius:8px;border:1px solid var(--border);background:var(--container)"><div style="display:flex;align-items:center;height:56px;padding:0 24px;border-bottom:1px solid var(--border)"><span layer-name="Title" style="font-size:16px;font-weight:600;color:var(--text)">Card title</span></div><div style="padding:24px"><span layer-name="Description" style="font-size:14px;color:var(--text)">Card content</span></div></div>"#,
|
||||
import: r#"import { Card } from "antd""#,
|
||||
react: "<Card title=\"{Title}\">{Description}</Card>",
|
||||
egui: "egui::Frame::group(ui.style()).show(ui, |ui| {\n ui.strong({Title});\n ui.separator();\n ui.label({Description});\n});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Switch",
|
||||
html: r#"<div layer-name="Switch" style="display:flex;align-items:center;justify-content:flex-end;width:44px;height:22px;padding:2px;border-radius:11px;background:var(--primary)"><div style="width:18px;height:18px;border-radius:9px;background:#ffffff"></div></div>"#,
|
||||
import: r#"import { Switch } from "antd""#,
|
||||
react: "<Switch defaultChecked />",
|
||||
egui: "ui.toggle_value(&mut on, \"\");",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Alert",
|
||||
html: r#"<div layer-name="Alert" style="display:flex;flex-direction:column;gap:4px;width:360px;padding:9px 12px;border-radius:8px;background:var(--info-bg);border:1px solid var(--info-border)"><span layer-name="Title" style="font-size:14px;color:var(--text)">Informational Notes</span><span layer-name="Description" style="font-size:14px;color:var(--text-secondary)">Additional description and information.</span></div>"#,
|
||||
import: r#"import { Alert } from "antd""#,
|
||||
react: "<Alert message=\"{Title}\" description=\"{Description}\" type=\"info\" showIcon />",
|
||||
egui: "ui.group(|ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
|
||||
},
|
||||
],
|
||||
},
|
||||
Kit {
|
||||
id: "reactbits",
|
||||
name: "React Bits",
|
||||
about: "Animated text, backgrounds and interactive pieces (reactbits.dev) to put on top of any style: dark stages, glowing accents.",
|
||||
light: &[
|
||||
("stage", "#f4f1ff"), ("surface", "#ffffff"), ("text", "#110b2a"), ("text-muted", "#5b5677"),
|
||||
("accent", "#5227ff"), ("accent-2", "#7cff67"), ("border", "#e2dcfa"),
|
||||
],
|
||||
dark: &[
|
||||
("stage", "#060010"), ("surface", "#120a24"), ("text", "#ffffff"), ("text-muted", "#a59cc5"),
|
||||
("accent", "#7b5cff"), ("accent-2", "#7cff67"), ("border", "#2a1f4a"),
|
||||
],
|
||||
tokens: &[("radius", "16px"), ("radius-pill", "999px")],
|
||||
surface: "stage",
|
||||
components: &[
|
||||
KitComponent {
|
||||
name: "Split Text",
|
||||
html: r#"<div layer-name="Split Text" style="display:flex"><span layer-name="Text" style="font-size:48px;font-weight:700;color:var(--text)">Hello, you!</span></div>"#,
|
||||
import: r#"import SplitText from "@/components/SplitText""#,
|
||||
react: r#"<SplitText text="{Text}" className="text-5xl font-bold" delay={40} />"#,
|
||||
egui: "ui.heading({Text});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Shiny Text",
|
||||
html: r#"<div layer-name="Shiny Text" style="display:flex"><span layer-name="Text" style="font-size:16px;font-weight:500;color:var(--text-muted)">Just some shiny text!</span></div>"#,
|
||||
import: r#"import ShinyText from "@/components/ShinyText""#,
|
||||
react: r#"<ShinyText text="{Text}" speed={3} />"#,
|
||||
egui: "ui.label({Text});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Gradient Text",
|
||||
html: r#"<div layer-name="Gradient Text" style="display:flex"><span layer-name="Text" style="font-size:40px;font-weight:700;color:var(--accent)">Gradient magic</span></div>"#,
|
||||
import: r#"import GradientText from "@/components/GradientText""#,
|
||||
react: r##"<GradientText colors={["#40ffaa", "#4079ff", "#40ffaa"]} animationSpeed={3}>{Text}</GradientText>"##,
|
||||
egui: "ui.heading({Text});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Aurora",
|
||||
html: r#"<div layer-name="Aurora" style="width:480px;height:200px;border-radius:16px;background:linear-gradient(135deg, #5227ff 0%, #7cff67 55%, #5227ff 100%)"></div>"#,
|
||||
import: r#"import Aurora from "@/components/Aurora""#,
|
||||
react: r##"<Aurora colorStops={["#5227FF", "#7CFF67", "#5227FF"]} amplitude={1} blend={0.5} />"##,
|
||||
egui: "",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Spotlight Card",
|
||||
html: r#"<div layer-name="Spotlight Card" style="display:flex;flex-direction:column;gap:8px;width:300px;padding:28px;border-radius:20px;border:1px solid var(--border);background:radial-gradient(circle at 30% 20%, #2b1a5c 0%, #120a24 70%)"><span layer-name="Title" style="font-size:18px;font-weight:600;color:#ffffff">Spotlight</span><span layer-name="Description" style="font-size:14px;color:#a59cc5">Move your pointer over the card.</span></div>"#,
|
||||
import: r#"import SpotlightCard from "@/components/SpotlightCard""#,
|
||||
react: "<SpotlightCard spotlightColor=\"rgba(82, 39, 255, 0.25)\">\n <h3>{Title}</h3>\n <p>{Description}</p>\n</SpotlightCard>",
|
||||
egui: "egui::Frame::group(ui.style()).show(ui, |ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Dock",
|
||||
html: r#"<div layer-name="Dock" style="display:flex;gap:12px;align-items:flex-end;padding:10px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface)"><div style="width:44px;height:44px;border-radius:12px;background:#2a1f4a"></div><div style="width:52px;height:52px;border-radius:14px;background:var(--accent)"></div><div style="width:44px;height:44px;border-radius:12px;background:#2a1f4a"></div><div style="width:44px;height:44px;border-radius:12px;background:#2a1f4a"></div></div>"#,
|
||||
import: r#"import Dock from "@/components/Dock""#,
|
||||
react: "<Dock items={items} panelHeight={68} baseItemSize={50} magnification={70} />",
|
||||
egui: "",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Magnet Button",
|
||||
html: r#"<div layer-name="Magnet Button" style="display:flex;align-items:center;justify-content:center;height:44px;padding:0 22px;border-radius:999px;background:var(--accent)"><span layer-name="Label" style="font-size:15px;font-weight:600;color:#ffffff">Hover me</span></div>"#,
|
||||
import: r#"import Magnet from "@/components/Magnet""#,
|
||||
react: "<Magnet padding={50} magnetStrength={3}>\n <button className=\"rounded-full bg-[#5227ff] px-5 py-2.5 font-semibold text-white\">{Label}</button>\n</Magnet>",
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
],
|
||||
},
|
||||
Kit {
|
||||
id: "egui",
|
||||
name: "egui",
|
||||
about: "Native egui widgets for Rust apps: compact (20px rows), small type, square-ish corners, the default dark visuals.",
|
||||
light: &[
|
||||
("panel", "#f8f8f8"), ("widget", "#e6e6e6"), ("widget-hover", "#dcdcdc"), ("text", "#3c3c3c"),
|
||||
("text-strong", "#000000"), ("text-weak", "#8c8c8c"), ("selection", "#90d1ff"), ("stroke", "#bebebe"), ("field", "#ffffff"),
|
||||
],
|
||||
dark: &[
|
||||
("panel", "#1b1b1b"), ("widget", "#3c3c3c"), ("widget-hover", "#464646"), ("text", "#b4b4b4"),
|
||||
("text-strong", "#ffffff"), ("text-weak", "#8c8c8c"), ("selection", "#005c80"), ("stroke", "#3c3c3c"), ("field", "#0f0f0f"),
|
||||
],
|
||||
tokens: &[("radius", "3px"), ("row-height", "20px"), ("body", "13px"), ("item-spacing", "8px")],
|
||||
surface: "panel",
|
||||
components: &[
|
||||
KitComponent {
|
||||
name: "Button",
|
||||
html: r#"<div layer-name="Button" style="display:flex;align-items:center;justify-content:center;height:20px;padding:0 6px;border-radius:3px;background:var(--widget)"><span layer-name="Label" style="font-size:13px;color:var(--text)">Click me</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: EGUI_BUTTON,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Checkbox",
|
||||
html: r#"<div layer-name="Checkbox" style="display:flex;align-items:center;gap:6px"><div style="width:14px;height:14px;border-radius:3px;background:var(--widget)"></div><span layer-name="Label" style="font-size:13px;color:var(--text)">Enabled</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: "ui.checkbox(&mut checked, {Label});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Toggle",
|
||||
html: r#"<div layer-name="Toggle" style="display:flex;align-items:center;justify-content:center;height:20px;padding:0 6px;border-radius:3px;background:var(--selection)"><span layer-name="Label" style="font-size:13px;color:var(--text-strong)">Grid</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: "ui.toggle_value(&mut on, {Label});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Text Edit",
|
||||
html: r#"<div layer-name="Text Edit" style="display:flex;align-items:center;width:200px;height:20px;padding:0 4px;border-radius:3px;background:var(--field);border:1px solid var(--stroke)"><span layer-name="Placeholder" style="font-size:13px;color:var(--text-weak)">Search…</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: EGUI_INPUT,
|
||||
},
|
||||
KitComponent {
|
||||
name: "Slider",
|
||||
html: r#"<div layer-name="Slider" style="display:flex;align-items:center;gap:8px"><div style="display:flex;align-items:center;width:140px;height:4px;border-radius:2px;background:var(--widget)"><div style="width:84px;height:4px;border-radius:2px;background:var(--selection)"></div></div><span layer-name="Label" style="font-size:13px;color:var(--text)">Volume</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: "ui.add(egui::Slider::new(&mut value, 0.0..=100.0).text({Label}));",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Combo Box",
|
||||
html: r#"<div layer-name="Combo Box" style="display:flex;align-items:center;gap:8px"><div style="display:flex;align-items:center;justify-content:space-between;width:140px;height:20px;padding:0 6px;border-radius:3px;background:var(--widget)"><span layer-name="Value" style="font-size:13px;color:var(--text)">First</span><svg width="10" height="10" viewBox="0 0 10 10" style="color:var(--text)"><path d="M2 3.5l3 3 3-3" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div><span layer-name="Label" style="font-size:13px;color:var(--text)">Choice</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: "egui::ComboBox::from_label({Label})\n .selected_text({Value})\n .show_ui(ui, |ui| {\n // ui.selectable_value(&mut choice, Choice::First, \"First\");\n });",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Heading",
|
||||
html: r#"<div layer-name="Heading" style="display:flex"><span layer-name="Text" style="font-size:20px;color:var(--text-strong)">Settings</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: "ui.heading({Text});",
|
||||
},
|
||||
KitComponent {
|
||||
name: "Collapsing Header",
|
||||
html: r#"<div layer-name="Collapsing Header" style="display:flex;align-items:center;gap:6px;height:20px"><svg width="10" height="10" viewBox="0 0 10 10" style="color:var(--text)"><path d="M2 3.5l3 3 3-3" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><span layer-name="Title" style="font-size:13px;color:var(--text-strong)">Advanced</span></div>"#,
|
||||
import: "",
|
||||
react: "",
|
||||
egui: "ui.collapsing({Title}, |ui| {\n // body\n});",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
pub fn kit(id: &str) -> Option<&'static Kit> {
|
||||
KITS.iter().find(|k| k.id == id)
|
||||
}
|
||||
@@ -898,6 +898,11 @@ impl Host for Web {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rename_project(&mut self, name: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dir = self.session.rename_open(&mut self.ed, name)?;
|
||||
self.st.project_name = self.session.project_name();
|
||||
Ok(dir)
|
||||
}
|
||||
/// Pixels are read back asynchronously here: `get_screenshot` defers to the shell.
|
||||
fn gpu(&mut self) -> Option<Headless> {
|
||||
None
|
||||
|
||||
@@ -43,6 +43,28 @@ fn duplicate_nodes_copies_annotations_same_page_and_across_pages() {
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn moving_a_frame_to_another_page_takes_its_notes() {
|
||||
// Issue #12.
|
||||
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", "display": "flex"}}));
|
||||
let kid = call(h, "write_html", json!({"targetNodeId": ab["id"], "html": "<span>Hi</span>"}))["createdNodeIds"][0].clone();
|
||||
h.ed.add_annotation(Annotation::new(Some(num(&kid)), "rule", "data"));
|
||||
let b = call(h, "create_page", json!({"name": "B"}))["pageId"].clone();
|
||||
call(h, "move_nodes", json!({"moves": [{"nodeId": ab["id"], "parentId": "root", "toPageId": b}]}));
|
||||
assert!(h.ed.page_doc(0).annotations.is_empty(), "nothing left behind detached");
|
||||
let doc = h.ed.page_doc(1);
|
||||
let note = &doc.annotations[0];
|
||||
assert!(!note.detached && doc.get(note.target.unwrap()).is_some_and(|n| n.kind.text().is_some_and(|t| t == "Hi")));
|
||||
// A detached note is reachable by the reference get_annotations shows for it.
|
||||
let id = h.ed.add_annotation(Annotation::new(Some(NodeId(9999)), "lost", ""));
|
||||
h.ed.update_annotation(id, |a| { a.target = None; a.detached = true; });
|
||||
call(h, "delete_annotations", json!({"refs": ["Detached #1"]}));
|
||||
}
|
||||
|
||||
|
||||
struct Fixture {
|
||||
host: exec::SessionHost,
|
||||
path: std::path::PathBuf,
|
||||
|
||||
@@ -33,8 +33,14 @@ pub fn gpu() -> Option<&'static Headless> {
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Where a test screenshot goes: the build's temp folder, or `docs/screenshots` when
|
||||
/// `PAPERCRAFT_UPDATE_SHOTS` is set, so a test run does not rewrite the committed images.
|
||||
pub fn shot(name: &str) -> PathBuf {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots");
|
||||
let dir = if std::env::var_os("PAPERCRAFT_UPDATE_SHOTS").is_some() {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots")
|
||||
} else {
|
||||
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("screenshots")
|
||||
};
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
@@ -139,3 +139,112 @@ fn write_html_instance_of_another_pages_main_links_instead_of_copying() {
|
||||
call(&mut h, "update_styles", json!({"pageId": a, "updates": [{"nodeIds": [comp], "styles": {"background": "#0000ff"}}]}));
|
||||
assert!(!bg_red(&h, 1, &key), "the instance follows the main on A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_mains_to_another_page_keeps_every_instance_linked() {
|
||||
// Issue #10: a frame of mains moved to a Design System page kept no links.
|
||||
let mut h = host();
|
||||
let (a, b, comp) = setup(&mut h);
|
||||
let c = call(&mut h, "create_page", json!({"name": "C"}))["pageId"].clone();
|
||||
let key = call(&mut h, "get_components", json!({}))["components"][0]["componentKey"].clone();
|
||||
let same = call(&mut h, "create_instance", json!({"pageId": a, "componentId": comp}));
|
||||
let other = call(&mut h, "create_instance", json!({"pageId": c, "componentId": comp}));
|
||||
call(&mut h, "move_nodes", json!({"pageId": a, "moves": [{"nodeId": comp, "parentId": "root", "toPageId": b}]}));
|
||||
let comps = call(&mut h, "get_components", json!({"pageId": b}));
|
||||
let moved = &comps["components"][0];
|
||||
assert_eq!(moved["componentKey"], key, "the key survives the move: {comps}");
|
||||
assert_eq!(moved["uses"], 2, "{comps}");
|
||||
call(&mut h, "update_styles", json!({"pageId": b, "updates": [{"nodeIds": [moved["id"]], "styles": {"background": "#0000ff"}}]}));
|
||||
assert!(!bg_red(&h, 0, &same["id"]), "the instance left on A follows the moved main");
|
||||
assert!(!bg_red(&h, 2, &other["id"]), "the instance on C follows it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_file_renames_the_open_project_and_keeps_it_open() {
|
||||
let mut h = host();
|
||||
let r = call(&mut h, "rename_file", json!({"name": "Renamed"}));
|
||||
let dir = std::path::PathBuf::from(r["path"].as_str().unwrap());
|
||||
assert!(dir.ends_with("Renamed") && dir.is_dir(), "{r}");
|
||||
assert_eq!(h.session.project_name(), "Renamed");
|
||||
call(&mut h, "create_artboard", json!({"name": "After", "styles": {"width": "10px", "height": "10px"}}));
|
||||
call(&mut h, "finish_working_on_nodes", json!({}));
|
||||
assert!(dir.is_dir(), "saves go to the renamed folder");
|
||||
assert!(try_call(&mut h, "rename_file", json!({"name": " "})).is_err());
|
||||
}
|
||||
|
||||
/// Badge / Green and Badge / Amber mains, a Row main holding a Green badge, and a Screen with
|
||||
/// one Row instance. Returns (row main id, screen id, the nested badge inside the row instance).
|
||||
fn rows(h: &mut exec::SessionHost) -> (Value, Value, NodeId) {
|
||||
for (name, bg, label) in [("Badge / Green", "#00ff00", "OK"), ("Badge / Amber", "#ffbf00", "-")] {
|
||||
let ab = call(h, "create_artboard", json!({"name": name, "styles": {"width": "60px", "height": "20px", "background": bg, "display": "flex"}}));
|
||||
call(h, "write_html", json!({"targetNodeId": ab["id"], "html": format!("<span layer-name=\"Label\">{label}</span>")}));
|
||||
call(h, "create_component", json!({"nodeId": ab["id"]}));
|
||||
}
|
||||
let row = call(h, "create_artboard", json!({"name": "Row", "styles": {"width": "200px", "height": "40px", "display": "flex", "gap": "8px"}}));
|
||||
call(h, "write_html", json!({"targetNodeId": row["id"], "html": "<x-paper-instance component=\"Badge / Green\"/>"}));
|
||||
call(h, "create_component", json!({"nodeId": row["id"]}));
|
||||
let screen = call(h, "create_artboard", json!({"name": "Screen", "styles": {"width": "300px", "height": "200px", "display": "flex"}}));
|
||||
call(h, "write_html", json!({"targetNodeId": screen["id"], "html": "<x-paper-instance component=\"Row\"/>"}));
|
||||
let row_inst = h.ed.doc.get(num(&screen["id"])).unwrap().children[0];
|
||||
let badge = h.ed.doc.get(row_inst).unwrap().children[0];
|
||||
(row["id"].clone(), screen["id"].clone(), badge)
|
||||
}
|
||||
|
||||
fn badge_state(h: &exec::SessionHost, screen: &Value) -> (String, String) {
|
||||
use papercraft::model::Paint;
|
||||
let doc = &h.ed.doc;
|
||||
let row_inst = doc.get(num(screen)).unwrap().children[0];
|
||||
let badge = doc.get(doc.get(row_inst).unwrap().children[0]).unwrap();
|
||||
let label = doc.get(badge.children[0]).unwrap().kind.text().map(|t| t.to_string()).unwrap_or_default();
|
||||
let fill = match badge.style.fills.first() {
|
||||
Some(Paint::Solid { color, .. }) => color.to_hex(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(label, fill)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_instance_replaced_inside_an_instance_survives_edits_to_the_outer_main() {
|
||||
// Issue #11.
|
||||
let mut h = host();
|
||||
let (row, screen, badge) = rows(&mut h);
|
||||
let key = format!("{}:{}", &h.ed.pages[0].id[..6], badge.0);
|
||||
call(&mut h, "write_html", json!({"mode": "replace", "targetNodeId": key,
|
||||
"html": "<x-paper-instance component=\"Badge / Amber\" overrides='{\"Label\":\"Late\"}'/>"}));
|
||||
assert_eq!(badge_state(&h, &screen), ("Late".into(), "#ffbf00".into()));
|
||||
call(&mut h, "update_styles", json!({"updates": [{"nodeIds": [row], "styles": {"gap": "0px"}}]}));
|
||||
assert_eq!(badge_state(&h, &screen), ("Late".into(), "#ffbf00".into()), "the swap survives the outer main's edit");
|
||||
// Copies of the screen keep it too.
|
||||
let copy = call(&mut h, "create_artboard", json!({"name": "Copy", "styles": {"width": "300px", "height": "200px", "display": "flex"}}));
|
||||
let row_inst = h.ed.doc.get(num(&screen)).unwrap().children[0];
|
||||
let clone = format!("<x-paper-clone node-id=\"{}:{}\"/>", &h.ed.pages[0].id[..6], row_inst.0);
|
||||
call(&mut h, "write_html", json!({"targetNodeId": copy["id"], "html": clone}));
|
||||
call(&mut h, "update_styles", json!({"updates": [{"nodeIds": [row], "styles": {"gap": "4px"}}]}));
|
||||
assert_eq!(badge_state(&h, ©["id"]), ("Late".into(), "#ffbf00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_instance_swaps_a_nested_instance_and_keeps_it() {
|
||||
let mut h = host();
|
||||
let (row, screen, badge) = rows(&mut h);
|
||||
let key = format!("{}:{}", &h.ed.pages[0].id[..6], badge.0);
|
||||
let comps = call(&mut h, "get_components", json!({}));
|
||||
let amber = comps["components"].as_array().unwrap().iter().find(|c| c["name"] == "Badge / Amber").unwrap()["id"].clone();
|
||||
call(&mut h, "swap_instance", json!({"nodeId": key, "componentId": amber}));
|
||||
assert_eq!(badge_state(&h, &screen).1, "#ffbf00");
|
||||
call(&mut h, "update_styles", json!({"updates": [{"nodeIds": [row], "styles": {"gap": "0px"}}]}));
|
||||
assert_eq!(badge_state(&h, &screen).1, "#ffbf00", "kept after the outer main changed");
|
||||
let comps = call(&mut h, "get_components", json!({}));
|
||||
let uses = |name: &str| comps["components"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap()["uses"].clone();
|
||||
assert_eq!(uses("Badge / Amber"), 1, "the swapped badge counts for Amber: {comps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_styles_name_the_variable_a_text_colour_is_bound_to() {
|
||||
let mut h = host();
|
||||
call(&mut h, "set_variables", json!({"variables": {"text": "#cdd6f4"}}));
|
||||
let ab = call(&mut h, "create_artboard", json!({"name": "T", "styles": {"width": "100px", "height": "40px", "display": "flex"}}));
|
||||
let t = call(&mut h, "write_html", json!({"targetNodeId": ab["id"], "html": "<span style=\"color: var(--text)\">Hi</span>"}))["createdNodeIds"][0].clone();
|
||||
let s = call(&mut h, "get_computed_styles", json!({"nodeIds": [t]}));
|
||||
assert_eq!(s[t.as_str().unwrap()]["color"], "var(--text)", "{s}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Headless GPU tests: render real scenes through wgpu (lavapipe on CI/VPS), save screenshots to
|
||||
//! `docs/screenshots/`, and assert pixels at known positions.
|
||||
//! Headless GPU tests: render real scenes through wgpu (lavapipe on CI/VPS), save screenshots
|
||||
//! (to `docs/screenshots/` with `PAPERCRAFT_UPDATE_SHOTS=1`, see `common::shot`), and assert
|
||||
//! pixels at known positions.
|
||||
|
||||
mod common;
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ fn agent_design_screenshot() {
|
||||
let Out::Image { data, .. } = &outs[0] else {
|
||||
panic!()
|
||||
};
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots");
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
|
||||
std::fs::write(dir.join("milestone7_mcp.png"), data).unwrap();
|
||||
let ab_node = h.ed.doc.get(num(&id)).unwrap();
|
||||
assert!(
|
||||
|
||||