Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e32acfeff | |||
| 999e9cbc36 | |||
| c71f9f3dc2 | |||
| d3e0351d05 | |||
| 4108ecdce9 | |||
| 4bf3e2a1df | |||
| e9da5eeba5 |
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
|
||||
@@ -76,6 +76,21 @@ 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.
|
||||
|
||||
|
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,13 +111,15 @@ 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` |
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -399,7 +399,7 @@ fn instance_comp_attr(doc: &Document, n: &Node) -> String {
|
||||
|
||||
/// 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.main)).and_then(|m| m.code.as_ref()))
|
||||
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`
|
||||
@@ -1396,7 +1396,7 @@ fn build_spec_tree(
|
||||
|
||||
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(
|
||||
@@ -1756,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) {
|
||||
@@ -1806,7 +1806,7 @@ fn library_components(doc: &Document, root: NodeId) -> Vec<Value> {
|
||||
stack.extend(n.children.iter().rev());
|
||||
continue;
|
||||
};
|
||||
let main = n.instance.as_ref().map_or(id, |l| l.main);
|
||||
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)),
|
||||
@@ -2009,6 +2009,7 @@ mod tests {
|
||||
main: comp_id,
|
||||
overrides: Vec::new(),
|
||||
source: None,
|
||||
swap: None,
|
||||
});
|
||||
frame.layout = Some(AutoLayout {
|
||||
direction: Direction::Vertical,
|
||||
@@ -2232,7 +2233,7 @@ mod tests {
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -316,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),
|
||||
@@ -482,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"));
|
||||
@@ -1862,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() {
|
||||
@@ -3499,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"));
|
||||
@@ -3524,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,
|
||||
@@ -3531,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
|
||||
@@ -3553,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 {
|
||||
@@ -3789,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() {
|
||||
@@ -4709,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,
|
||||
@@ -4717,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5094,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());
|
||||
@@ -5344,7 +5403,7 @@ fn set_code_link(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let n = doc.get(id).ok_or("no such node")?;
|
||||
let main = match (&n.instance, n.component) {
|
||||
(_, true) => id,
|
||||
(Some(l), false) if doc.get(l.main).is_some() => l.main,
|
||||
(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()),
|
||||
};
|
||||
@@ -5518,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();
|
||||
@@ -5528,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
|
||||
@@ -5653,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)
|
||||
@@ -5679,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" | "report_issue" | "design_rules"
|
||||
"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 \
|
||||
@@ -338,6 +346,21 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["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, \
|
||||
@@ -844,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()}),
|
||||
@@ -1072,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" | "report_issue" | "design_rules" | "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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -55,6 +55,15 @@ const RESEARCH_LINE: &str = "[Research] Web research is on";
|
||||
/// 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:
|
||||
@@ -67,6 +76,7 @@ Build.
|
||||
* 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).
|
||||
|
||||
@@ -327,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.
|
||||
@@ -435,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
|
||||
}
|
||||
@@ -529,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).
|
||||
@@ -707,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();
|
||||
}
|
||||
@@ -721,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);
|
||||
@@ -738,11 +788,13 @@ impl ChatState {
|
||||
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}{playbook}{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
|
||||
)
|
||||
}
|
||||
@@ -1890,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));
|
||||
@@ -1927,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"
|
||||
};
|
||||
@@ -1943,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);
|
||||
@@ -1999,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");
|
||||
|
||||
@@ -1097,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)| {
|
||||
@@ -1474,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 = [
|
||||
|
||||
@@ -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,6 +518,9 @@ 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;
|
||||
|
||||
@@ -1119,7 +1121,10 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
}
|
||||
});
|
||||
});
|
||||
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");
|
||||
@@ -1153,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
|
||||
@@ -1221,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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +113,11 @@ pub struct ChatPrefs {
|
||||
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 {
|
||||
@@ -136,6 +141,8 @@ impl Default for ChatPrefs {
|
||||
image_offer_seen: false,
|
||||
plugins: Vec::new(),
|
||||
issue_repo: ISSUE_REPO.into(),
|
||||
helper_command: String::new(),
|
||||
auto: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,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
|
||||
}
|
||||
@@ -1216,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) {
|
||||
@@ -1275,6 +1313,8 @@ mod tests {
|
||||
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());
|
||||
|
||||
@@ -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!(
|
||||
|
||||