Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 238249309f | |||
| 35dc8aed8f | |||
| 73353dd4ca | |||
| 33a0e265b8 | |||
| 45f197250b | |||
| 13bc2d582e | |||
| 92c63b0a46 | |||
| dcbc213d1f | |||
| 30dba88e1c |
@@ -33,6 +33,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -192,6 +203,112 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix 1.1.5",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix 1.1.5",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -316,6 +433,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -334,6 +460,19 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -360,6 +499,12 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -422,6 +567,15 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.7.0"
|
||||
@@ -469,6 +623,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -683,6 +847,35 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-secret-service"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-padding",
|
||||
"cbc",
|
||||
"dbus",
|
||||
"fastrand",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"sha2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
@@ -722,6 +915,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -894,6 +1088,33 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "2.0.0"
|
||||
@@ -979,6 +1200,26 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1171,6 +1412,19 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
@@ -1231,6 +1485,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1386,6 +1651,30 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -1708,6 +1997,16 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
@@ -1861,6 +2160,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"dbus-secret-service",
|
||||
"log",
|
||||
"secret-service",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework 3.7.0",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -1896,6 +2211,15 @@ version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
@@ -2042,6 +2366,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2157,7 +2490,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2192,6 +2525,19 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -2210,6 +2556,69 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2698,6 +3107,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -2709,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -2727,6 +3146,8 @@ dependencies = [
|
||||
"i_overlay",
|
||||
"image",
|
||||
"js-sys",
|
||||
"keyring",
|
||||
"libc",
|
||||
"log",
|
||||
"lyon",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -2757,6 +3178,12 @@ dependencies = [
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2837,6 +3264,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -2956,7 +3394,7 @@ checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"indexmap",
|
||||
"nix",
|
||||
"nix 0.31.3",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows",
|
||||
@@ -3010,13 +3448,24 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
@@ -3031,6 +3480,16 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
@@ -3041,6 +3500,15 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -3460,6 +3928,38 @@ dependencies = [
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand 0.8.8",
|
||||
"serde",
|
||||
"sha2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3560,6 +4060,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
@@ -3592,6 +4103,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3801,6 +4323,12 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.16.1"
|
||||
@@ -3860,10 +4388,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4241,6 +4769,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -5253,6 +5792,16 @@ version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkbcommon-dl"
|
||||
version = "0.4.2"
|
||||
@@ -5307,6 +5856,62 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"ordered-stream",
|
||||
"rand 0.8.8",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.62"
|
||||
@@ -5353,6 +5958,20 @@ name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -5413,3 +6032,40 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.4.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -93,6 +93,10 @@ axum = { version = "0.8", default-features = false, features = ["http1", "tokio"
|
||||
tokio-util = "0.7"
|
||||
# Shared-project sync; the system TLS stack so https servers work without bundling one.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
|
||||
# Anthropic API key in the OS keychain (chat API backend). Linux uses the Secret Service (persistent; keyutils is not).
|
||||
# libc: SIGINT to the spawned chat CLIs.
|
||||
keyring = { version = "3", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
|
||||
libc = "0.2"
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Progress
|
||||
|
||||
Last updated: 2026-10-21.
|
||||
Last updated: 2026-10-22.
|
||||
|
||||
## Phase 3: local-first design and whiteboard tool
|
||||
|
||||
@@ -34,7 +34,7 @@ are credited in `THIRD_PARTY.md`.
|
||||
to that page's own document and history (`Editor::with_page`) without switching the view.
|
||||
* **Bridge** (`paper-rs-mcp`): rmcp stdio server that lists tools offline and relays calls to
|
||||
the app over HTTP, reconnecting after app restarts.
|
||||
* **Tools** (`src/mcp/tools.rs`): 53 tools, using Paper's names and argument shapes where there is an
|
||||
* **Tools** (`src/mcp/tools.rs`): 53 tools at the time (61 now), using Paper's names and argument shapes where there is an
|
||||
equivalent (see README), plus `create_shape`, `select_nodes`, `undo`, `redo`.
|
||||
`write_html` (`src/mcp/{html,css}.rs`, `exec.rs` `Builder`) maps flex/block layout onto auto
|
||||
layout with CSS stretch semantics, text styles and rich-text runs, backgrounds, gradients,
|
||||
@@ -76,12 +76,39 @@ are credited in `THIRD_PARTY.md`.
|
||||
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
|
||||
moving layers between pages; remote image URLs (see README Known gaps).
|
||||
|
||||
### 2026-10-22: 0.6.1 (agents via ACP, annotations)
|
||||
|
||||
* Agents are found through the login shell's PATH (read once at startup, 3 s limit, merged with the app PATH and the usual folders), so an app opened from the Dock finds Claude Code, Codex and the rest. No install instructions anywhere; a missing agent shows Not found with Set path… and Docs.
|
||||
* ACP backend (`src/chat/acp.rs`): any agent that speaks the Agent Client Protocol over stdio, with PaperCraft's MCP server, streamed updates, permission flow, cancel and load. Registry as data in `src/chat/agents.rs`: native agents found on PATH, npx agents off until enabled, custom agents by name, command and arguments. Verified here: OpenCode works; Gemini CLI's server currently refuses ACP clients; Cursor needs `cursor-agent login`; Pi works through `pi-acp` after enabling.
|
||||
* Agent panel over the chat column (Detected, Enable via npx, Not found, API endpoints, Custom, search), a Connect an agent setup card, a sign-in card, and multiple `@ Selection` chips.
|
||||
* Settings → Chat redesigned: one agents list with details, endpoints, defaults, scrolling page on one label grid.
|
||||
* Send bug: a turn that cannot start keeps the typed text and shows an error row with Retry, never a silent no-op (`a_failed_start_keeps_the_text_and_shows_an_error`).
|
||||
* Annotations: notes with leader lines on a page, six categories with keys (style comes only from the category), project and per-page templates with built-ins and a Customize modal, view modes (⌘⇧A), Annotate tool (N), side view, inspector section, Annotate with AI, six MCP tools (`annotate` batches), and comments in `get_design_spec` and the code exports. Exports and Present leave annotations out.
|
||||
* Evidence: `src/chat/{path,agents,acp}.rs`, `src/ui/{chat_agents,annotations,annotations_canvas}.rs`, `src/model/annotation.rs`, `src/editor/annotations.rs`, `src/mcp/tools.rs`, `tests/annotations*.rs`, `specs/chat-agents/`, `specs/annotations/`, `docs/chat.md`, `docs/annotations.md`.
|
||||
|
||||
### 2026-10-21: Chat mode
|
||||
|
||||
* Chat layout next to the canvas (⌘J, `Design | Chat`), with three backends: Claude Code and Codex through their CLIs (`claude -p` stream-json, `codex exec --json`) against the app's MCP server, and the Anthropic API with a key in the OS credential store. One event stream for all three; tool calls run through the same MCP dispatch, so runs, review and revert behave as before.
|
||||
* Thread: markdown replies, thinking row, tool cards, step groups, plan from `TodoWrite`, approval for destructive tools on the API backend, run checkpoints with Restore to here, usage line and context meter, `@ Selection` and image attachments, Stop.
|
||||
* Chats live in the project's `chats/` folder. Settings → Chat holds CLI paths, the default backend and the API key.
|
||||
* Offline CLI fix: an offline save writes no camera, and a top-level shape created without a position goes to the next free spot instead of the origin.
|
||||
* Evidence: `src/chat/`, `src/ui/chat.rs`, `src/ui/chat_msgs.rs`, `src/ui/chat_md.rs`, `src/ui/settings.rs`, `src/mcp/exec.rs`, `tests/fixtures/chat/`, `specs/chat/`, `docs/chat.md`.
|
||||
|
||||
### 2026-10-21: Agent runs and change review
|
||||
|
||||
* Agent editing calls group into runs, snapshot each edited page as `Before agent run N`, and end on 30 seconds idle, `finish_working_on_nodes`, or project switch. Runs persist under `runs/` and keep the newest 50.
|
||||
* Agent view lists runs and supports review, whole-run revert, per-change undo and accept all. Review adds canvas outlines and a finish toast; palette commands review or revert the last run.
|
||||
* Id-paired page diffs power review; History version compare UI remains todo. Completed with full restore (including variables, modes and tokens), tool edit classification, and wasm support.
|
||||
|
||||
### 2026-10-21: Design libraries and offline CLI
|
||||
|
||||
* Variables are now collections with modes, slash groups, four types and aliases, stored in `design.json` (project version 2; 0.4.0 refuses migrated projects). Each page keeps a derived cache; every edit is one undo step per page, and the page-only removals are gone. Per-page active mode per collection.
|
||||
* Fields bind to variables (stroke, text color, size, gap, padding, radius, font size, line height, opacity, visible) in the inspector and with `bind_variable`; gradients do not bind yet.
|
||||
* Instances work across pages (`component_key`, source page or library); deleting a main flags its instances.
|
||||
* Libraries: publish a snapshot (`library.json`), enable, update with review (overrides kept), disable, and keep working when the source is missing. Variables modal, Libraries modal and Assets view in the UI; `create_collection`, `delete_variables`, `bind_variable` and five `*_library` tools in MCP (61 tools).
|
||||
* `papercraft-cli call --offline` and `papercraft-cli shell` run the tools in-process on the project store, refuse a project the app has open, and fall back to the software adapter for screenshots. See `docs/agents.md`.
|
||||
* Evidence: `src/variables.rs`, `src/editor/design.rs`, `src/libraries.rs`, `src/cli.rs`, `tests/{libraries,cross_page_components,variables_ui,assets_panel,cli_offline}.rs`, specs in `specs/design-libraries/` and `specs/cli-offline/`.
|
||||
|
||||
### P6 notes
|
||||
|
||||
* **Export** (`io/export.rs`): scope = selection, each frame, page or all pages; format =
|
||||
|
||||
@@ -1,365 +1,291 @@
|
||||
<h1 align="center">PaperCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Design and whiteboarding on an infinite canvas; a native, local-first, agent-driven design tool, built in pure Rust.</b>
|
||||
<b>A native, local-first design and whiteboard canvas that AI agents can drive.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Frames, auto layout, components, variables and icons, plus a hand-drawn whiteboard mode,<br>
|
||||
in a native app written entirely in Rust. Open source, offline, and drivable by AI agents.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="Native on egui and wgpu" src="https://img.shields.io/badge/egui%20%C2%B7%20wgpu-native-2f7bf5?style=flat-square">
|
||||
<img alt="MCP server and CLI for agents" src="https://img.shields.io/badge/agents-MCP%20%2B%20CLI-555555?style=flat-square">
|
||||
<img alt="Status: alpha" src="https://img.shields.io/badge/status-alpha-d69e2e?style=flat-square">
|
||||
<img alt="Rust" src="https://img.shields.io/badge/Rust-egui%20%C2%B7%20wgpu-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="Agents: MCP and CLI" src="https://img.shields.io/badge/agents-MCP%20%2B%20CLI-555555?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/app_demo.png" alt="PaperCraft with a launch plan: a Week plan frame with sticky notes and a progress bar, a Sketches frame with hand-drawn shapes, the Layers panel and the Design inspector" width="100%">
|
||||
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Redesign page of its own design file open: layers on the left, the canvas in the middle, the inspector on the right" width="100%">
|
||||
<br>
|
||||
<sub>A planning board: frames, sticky notes, a progress bar, hand-drawn sketches, the Layers panel and the Design inspector.</sub>
|
||||
<sub>PaperCraft editing its own design file (the Redesign page).</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-it-is">What it is</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#drive-it-with-agents">Agents</a> ·
|
||||
<a href="#design-systems-variables-components-icons">Design systems</a> ·
|
||||
<a href="#agents">Agents</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#keyboard-shortcuts">Shortcuts</a> ·
|
||||
<a href="#status">Status</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#status-and-known-limits">Status</a> ·
|
||||
<a href="#project-layout">Layout</a> ·
|
||||
<a href="#license-and-credits">License</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
## What it is
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Local-first</h3>
|
||||
Projects are plain folders of JSON in <code>~/Documents/papercraft</code>. Autosave with atomic writes, no account, no cloud.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Native and fast</h3>
|
||||
egui on wgpu (Metal, Vulkan, DX12). No Electron, no web view. Cached tessellation: a frame with 10k visible nodes draws in about 3 ms.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Design and whiteboard</h3>
|
||||
Auto layout, components and variables next to hand-drawn shapes, bound arrows, sticky notes and a laser pointer, on the same canvas.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Agent-ready</h3>
|
||||
40 MCP tools and a shell CLI. Agents write HTML into layers, screenshot the result and lint it, one undo step per call.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
PaperCraft is an infinite-canvas design tool and whiteboard written in Rust (egui on wgpu). It runs as a desktop app with no account and no cloud: a project is a folder of JSON files on your disk. It also serves the Model Context Protocol and ships a CLI, so an agent such as Claude Code or Codex can read and edit the open canvas, screenshot the result and lint it, one undo step per tool call.
|
||||
|
||||
<br>
|
||||
It is an alpha. It is good for building screens, design systems and sketches, and it is not a replacement for a mature design tool. See [Status and known limits](#status-and-known-limits).
|
||||
|
||||
## Features
|
||||
|
||||
**Canvas and layout**
|
||||
* Frames with clipping, groups, vector shapes with the Pen and Bézier editing, boolean operations, gradients, image fills, strokes, per-corner radii, opacity and blend modes, drop shadows, rich text, smart guides, measurements, align and distribute.
|
||||
* Auto layout (direction, gap, padding, hug, fill, wrap, space-between) and constraints.
|
||||
* A layers tree you can drag to reorder or re-parent, an inspector with Design and Prototype tabs, and a command palette over every action, page and layer (⌘K).
|
||||
* Whiteboard mode: hand-drawn (rough.js-style) shapes, arrows bound to shapes, elbow connectors, sticky notes, freehand ink and a laser pointer on the same canvas.
|
||||
* Pages: each page is its own canvas with its own undo history and view.
|
||||
|
||||
**Design systems**
|
||||
* **Components** work across pages and across projects. Edit a main component and its instances follow, except the properties an instance overrode.
|
||||
* **Shared libraries:** publish a project as a library, enable it in other projects, and take updates when it changes.
|
||||
* **Variables:** collections with modes, slash groups (`color/brand/primary`), types (color, number, string, boolean) and aliases. Fills, strokes, text color, size, gap, padding, radius, font size, line height, opacity and visibility bind to variables.
|
||||
* **Variables modal** (⌘⌥⇧V, or "Variables…" in the palette) to edit all of it; **Assets view** (side tab) with components grouped by name; **Libraries modal** to publish, enable and update.
|
||||
* Lint (`lint_design`) for contrast, the 4px grid, type scale, touch targets, default names and hard-coded colors, with auto-fix.
|
||||
* Design tokens out as CSS, JSON (W3C DTCG) or Tailwind `@theme`; code out as JSX with inline styles, Tailwind or HTML.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone3_figma_core.png" alt="A design with frames, auto layout, a component and its instances, and vector shapes" width="100%">
|
||||
<br>
|
||||
<h3>Design tools</h3>
|
||||
Frames with clipping, groups, components with instances and per-property overrides, auto layout (direction, gap, padding, hug, fill, wrap, space-between), constraints, the Pen with Bézier editing, boolean operations, gradients, image fills, strokes, per-corner radii, opacity and blend modes, rich text, smart guides and measurements, align and distribute.
|
||||
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with four Catppuccin modes" width="100%">
|
||||
<br><sub>Variables modal: collections, modes, groups and types.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone4_whiteboard.png" alt="A whiteboard with hand-drawn shapes, bound arrows, an elbow connector and sticky notes" width="100%">
|
||||
<br>
|
||||
<h3>A whiteboard that sketches</h3>
|
||||
Hand-drawn (rough.js-style) rectangles, ellipses, diamonds and lines, arrows that bind to shapes and re-route when they move, elbow connectors, sticky notes, freehand ink with pressure, a laser pointer and a shape library.
|
||||
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with components grouped into Banner, Button, Checkbox and Chip" width="100%">
|
||||
<br><sub>Assets view: components grouped by name, from this file and enabled libraries.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone5_inspector.png" alt="The inspector with position, size, constraints, auto layout, fills, strokes and export sections" width="100%">
|
||||
<br>
|
||||
<h3>Inspector, layers and a palette</h3>
|
||||
A sectioned inspector, a layers tree you can drag to reorder or re-parent, 100+ keyboard shortcuts, and a command palette over every action, page and layer (⌘K).
|
||||
<img src="docs/screenshots/readme/libraries.png" alt="The Libraries modal with one enabled library" width="100%">
|
||||
<br><sub>Libraries modal: publish this file, enable libraries from the store.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone6_svg_import.png" alt="An imported SVG on the canvas" width="100%">
|
||||
<br>
|
||||
<h3>Import and export</h3>
|
||||
Export a selection, each frame, a page or the whole project to PNG (1 to 4x), SVG or vector PDF. Import <code>.excalidraw</code> scenes and libraries, SVG and images; paste between pages, projects and apps.
|
||||
<img src="docs/screenshots/readme/agent-review.png" alt="Agent run review: a list of changes on the left and green, yellow and red outlines on the canvas" width="100%">
|
||||
<br><sub>Review of an agent run, with the changes outlined on the canvas.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Themes
|
||||
**Prototyping and review**
|
||||
* Prototype links between frames and a **Present** mode that follows them with a camera glide.
|
||||
* **Version history:** named and automatic saved versions per page; rename, restore, delete.
|
||||
* **Agent runs:** editing tool calls group into a run. PaperCraft saves an auto-version before the first edit, lists the run, and lets you review it (added, changed and removed layers, outlined on the canvas), revert one change, revert the whole run or accept it.
|
||||
* **Comments** on the canvas.
|
||||
|
||||
System, Light, Dark and the four Catppuccin flavours: Latte, Frappé, Macchiato and Mocha. Pick one in **View › Theme** or search "theme" in the ⌘K palette.
|
||||
**Sharing and platforms**
|
||||
* **Shared projects** through an optional relay (`papercraft-relay`); concurrent edits to a page are merged. Without a relay nothing leaves your machine.
|
||||
* **Themes:** System, Light, Dark and the four Catppuccin flavours (Latte, Frappé, Macchiato, Mocha).
|
||||
* **Web build** that runs the editor in the browser (`sh web/build.sh`, served by `papercraft-relay --static web`).
|
||||
* **Import and export:** Figma `.fig` files, `.excalidraw` scenes and libraries, SVG and images in; PNG (1 to 4x), SVG and vector PDF out, for a selection, each frame, a page or the whole project.
|
||||
|
||||
### Projects and pages
|
||||
Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file per page in `pages/`, plus `versions/`, `runs/` and library files). Work autosaves with atomic writes and files are versioned and migrated on load. `PAPERCRAFT_HOME` points to another folder.
|
||||
|
||||
PaperCraft opens on a **home screen** of your projects (recents grid with thumbnails; New, Import, and Rename, Duplicate or Move to Trash from each card's ⋯ menu or right-click; restore from Trash). A project is a folder in `~/Documents/papercraft/<name>/` with `project.json` and one JSON file per page in `pages/`. Work autosaves about a second after each change with crash-safe atomic writes; files are versioned and older versions migrate on load. Set `PAPERCRAFT_HOME` to use another folder. If you used the app under its earlier name, `~/Documents/paper-rs` is still read when it is the only one that exists.
|
||||
## Agents
|
||||
|
||||
Each project has any number of **pages** (the Pages list at the top of the left sidebar: + to add, double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its own infinite canvas with its own undo history and view. Copy and paste works between pages and projects.
|
||||
PaperCraft exposes **67 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
|
||||
|
||||
### File formats
|
||||
|
||||
| Format | Use |
|
||||
| Way | Notes |
|
||||
|---|---|
|
||||
| Project folder (`project.json` + `pages/*.json`) | The native format. Plain, versioned JSON. |
|
||||
| `.papercraft` | A single-file document (`papercraft FILE.papercraft`, not autosaved). `--import` turns it into a project. |
|
||||
| `.paper-rs` | The earlier single-file extension. Old files still open. |
|
||||
| PNG, SVG, PDF | Export: selection, frames, page or all pages. PDF is vector, with gradients, images and blend modes. |
|
||||
| `.excalidraw`, `.excalidrawlib`, SVG, images | Import (drag and drop, ⌘O, or `--import`). |
|
||||
|
||||
## Drive it with agents
|
||||
|
||||

|
||||
|
||||
PaperCraft serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so agents such as Claude Code and Codex can read and design on the open canvas. Every tool call runs through the editor like a user edit: one undo step per call, and the window repaints immediately. Tool names and arguments follow other design-tool MCP servers where there is an equivalent, so prompts written for them carry over.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<img src="docs/screenshots/agent_signin.png" alt="A dark sign-in screen built from a component, an icon and variables" width="100%">
|
||||
<br><sub>A sign-in screen: variables, an icon and a component instance.</sub>
|
||||
</td>
|
||||
<td width="67%" valign="top">
|
||||
<img src="docs/screenshots/agent_dashboard.png" alt="A dashboard with a sidebar, three stat cards and a bar chart" width="100%">
|
||||
<br><sub>A dashboard written in one <code>write_html</code> call, then checked with <code>lint_design</code>.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<img src="docs/screenshots/agent_landing.png" alt="A landing page with a hero and three feature cards" width="100%">
|
||||
<br><sub>A landing page: hero, call to action and feature cards, all colors from variables.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Loopback only; requests from browsers (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT=NNNN` changes the port; `PAPERCRAFT_MCP=0` turns the server off.
|
||||
* **stdio**: `papercraft-mcp` (built next to `papercraft`) relays stdio to the running app. It lists the tools even when the app is closed, and tells the agent to start PaperCraft if a call can't reach it.
|
||||
* **Shell**: `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context.
|
||||
|
||||
### Claude Code
|
||||
| MCP over HTTP | Built into the app: `http://127.0.0.1:29980/mcp`. Loopback only; browser requests (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT` changes the port, `PAPERCRAFT_MCP=0` turns it off. |
|
||||
| MCP over stdio | `papercraft-mcp` relays stdio to the running app. It lists tools even when the app is closed. |
|
||||
| Shell | `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context. |
|
||||
|
||||
```sh
|
||||
# HTTP (the app must be running)
|
||||
# Claude Code
|
||||
claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp
|
||||
# or stdio through the bridge
|
||||
claude mcp add --scope user papercraft -- /path/to/papercraft-mcp
|
||||
```
|
||||
|
||||
### Design tab
|
||||
|
||||
The sidebar's **Design** tab (next to Layers and Library) lists the project's color variables as
|
||||
editable swatches, switches variable modes (for example the four Catppuccin flavours), and lists the
|
||||
page's components with Go to and Insert buttons. Editing a variable recolours every fill that follows it.
|
||||
|
||||
### Codex
|
||||
|
||||
```sh
|
||||
# Codex
|
||||
codex mcp add papercraft --url http://127.0.0.1:29980/mcp
|
||||
# or stdio
|
||||
codex mcp add papercraft -- /path/to/papercraft-mcp
|
||||
```
|
||||
|
||||
which writes this to `~/.codex/config.toml` (either form works):
|
||||
The main write tool is `write_html`: HTML with inline styles becomes layers (flexbox maps onto auto layout), and `update_styles` takes the same CSS as JSON. Other groups cover reading and screenshots, variables and bindings, components, libraries, icons, lint, diffs, export and agent-run bookkeeping. The full list is `papercraft-cli list`; each schema is `papercraft-cli schema <tool>`; the reference is [skills/papercraft/references/tools.md](skills/papercraft/references/tools.md).
|
||||
|
||||
```toml
|
||||
[mcp_servers.papercraft]
|
||||
url = "http://127.0.0.1:29980/mcp"
|
||||
# command = "/path/to/papercraft-mcp"
|
||||
```
|
||||
### Without the app
|
||||
|
||||
Then ask the agent something like *"In PaperCraft, create a 390×844 mobile artboard and design a sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn style"*. The [`papercraft` and `papercraft-design` agent skills](skills) teach an agent the workflow (build, screenshot, lint, fix) and design rules; [docs/agents.md](docs/agents.md) has the full guide.
|
||||
|
||||
### Shell (no MCP client needed)
|
||||
|
||||
The app must be running with a project open.
|
||||
`papercraft-cli` works with PaperCraft closed. When nothing answers on the port, or with `--offline`, tools run inside the CLI on the project store and every editing call is saved; the project opens normally in the app later. With the app open on a project, `--offline` on that same project is refused, so two writers never share a folder. Screenshots use the GPU, or the software adapter when there is none.
|
||||
|
||||
```sh
|
||||
papercraft-cli list # tool names, one line each (works offline)
|
||||
papercraft-cli schema write_html # one tool's full schema
|
||||
papercraft-cli call get_basic_info
|
||||
papercraft-cli call create_artboard '{"name":"Hero","styles":{"width":"800px","height":"400px"}}'
|
||||
echo '{"nodeId":"12"}' | papercraft-cli call get_screenshot - --out hero.png
|
||||
papercraft-cli call create_file '{"name":"Demo"}' --offline
|
||||
papercraft-cli call get_screenshot '{"nodeId":"abc123:1"}' --project Demo -o card.png
|
||||
```
|
||||
|
||||
Text goes to stdout, images are saved to `--out` (default `<tool>.png`) and the path is printed, and the exit status is 1 on a tool error or when the app isn't running.
|
||||
|
||||
### Tools
|
||||
|
||||
| Group | Tools |
|
||||
|---|---|
|
||||
| Projects & pages | `list_resources`, `open_file`, `create_file`, `create_page`, `rename_pages`, `delete_pages` |
|
||||
| Read | `get_basic_info`, `get_selection`, `get_node_info`, `get_children`, `get_tree_summary`, `get_computed_styles`, `get_jsx`, `find_nodes`, `get_fill_image`, `get_font_family_info`, `get_guide` |
|
||||
| See | `get_screenshot` (PNG image content of a node or the page), `export` (PNG/SVG/PDF files), `export_combined_pdf` |
|
||||
| Write | `create_artboard`, `write_html`, `update_styles`, `set_text_content`, `rename_nodes`, `duplicate_nodes`, `move_nodes`, `delete_nodes`, `finish_working_on_nodes` (saves) |
|
||||
| Whiteboard and session | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
|
||||
| Design system | `get_variables`, `set_variables`, `set_variable_mode`, `get_components`, `create_component`, `create_instance`, `search_icons`, `lint_design` |
|
||||
|
||||
`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding, alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules; text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text runs (`<b>`, `<i>`, `<span style>`); boxes get backgrounds (colors, gradients, images), borders, radii, opacity and blend modes; `<img>` takes local paths or `data:` URIs and `<svg>` is imported as vectors. `update_styles` takes the same CSS as JSON, plus the hand-drawn keys `sketch`, `roughness`, `fillStyle`, `strokeStyle`. Node ids look like `3fa9c1:14` (page and node); `pageId` lets an agent work on another page without switching the user's view. The full reference is in [skills/papercraft/references/tools.md](skills/papercraft/references/tools.md).
|
||||
|
||||
## Design systems: variables, components, icons
|
||||
|
||||
Agents (and `papercraft-cli`) get the pieces of a design system, so files aren't a pile of hex codes and copies:
|
||||
`papercraft-cli shell` keeps one session open and reads a script from stdin. `:watch on` re-renders the artboard last edited to `preview.png` after every editing call (and shows it inline on iTerm2, WezTerm and kitty).
|
||||
|
||||
```sh
|
||||
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7"}}'
|
||||
papercraft-cli call write_html '{"targetNodeId":"root","mode":"insert-children","html":"<div style=\"background:var(--surface)\">…</div>"}'
|
||||
papercraft-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
|
||||
papercraft-cli call create_instance '{"componentId":"<id>","parentId":"<frame>"}' # then set_text_content on its layers
|
||||
papercraft-cli call lint_design '{"nodeId":"<frame>"}' # contrast, grid, type scale, names
|
||||
papercraft-cli icons add phosphor lucide # open icon sets from npm, with their licences
|
||||
papercraft-cli icons search arrow left
|
||||
# in write_html: <x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
|
||||
papercraft-cli shell --offline --project Demo <<'EOF'
|
||||
set_variables {"variables":{"bg":"#1e1e2e","surface":"#313244","text":"#cdd6f4","accent":"#cba6f7"}}
|
||||
:watch on
|
||||
write_html {
|
||||
"targetNodeId": "abc123:1",
|
||||
"mode": "insert-children",
|
||||
"html": "<div style='font-size:28px;color:var(--text)'>Welcome back</div>"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
* **Variables** are named colors per project (`get_variables`, `set_variables`). A fill or text color written as `var(--name)` stays linked; `set_variables` moves every linked fill on every page in one undoable step, and the link is saved in the file. `set_variables` with a `mode` stores one set of values per mode (Light, Dark) and `set_variable_mode` switches the whole project. Strokes and gradients keep fixed colors; fill-based icon sets (Phosphor, Material) follow variables, stroke sets (Lucide) don't.
|
||||
* **Components** (`create_component`, `create_instance`, `get_components`, `<x-paper-instance>`): edit the main component and every instance follows, except what an instance overrode (text, fills, size, ... are marked automatically when set through `set_text_content` / `update_styles`). Components and their instances live on one page.
|
||||
* **Icons** live in `<data dir>/papercraft/icons/<set>/` (override with `PAPERCRAFT_ICONS`). Sets: Lucide (ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0); each keeps its licence file. `examples/taskflow.py` builds an 8-screen app with all three.
|
||||
* **Lint** (`lint_design`) checks WCAG contrast, spacing on a 4px grid, the type scale, text under 12px, touch targets under 44px, default layer names and hard-coded colors that should be variables, and says how to fix each.
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/offline-shell.png" alt="preview.png written by the offline shell: a sign-in card with a heading, two fields and a button" width="240">
|
||||
<br>
|
||||
<sub><code>preview.png</code> from an offline shell script, with no app running.</sub>
|
||||
</p>
|
||||
|
||||
More in [docs/design-system.md](docs/design-system.md).
|
||||
### Chat
|
||||
|
||||
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code and Codex through their CLIs, any agent that speaks the Agent Client Protocol (ACP, such as OpenCode, Gemini CLI or Cursor) found on your PATH, or the Anthropic API with your own key. It shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left shows a finished agent run with its Review, Revert and Accept actions, and the canvas on the right" width="100%">
|
||||
<br>
|
||||
<sub>Chat mode with a finished agent run: review, revert or accept from the thread.</sub>
|
||||
</p>
|
||||
|
||||
### Annotations
|
||||
|
||||
Annotations are notes that explain a design in place: business rules, behaviour, data sources, accessibility and dev notes, each a numbered card with a leader line to its layer. Categories set the look, templates say which notes a page wants (product spec, dev handoff, accessibility review and more), and the view cycles between full notes, badges and hidden with ⌘⇧A. Exports and Present mode leave them out. Agents add them in one `annotate` call. See [docs/annotations.md](docs/annotations.md).
|
||||
|
||||
### Skills
|
||||
|
||||
[`skills/`](skills) holds agent skills: `papercraft` (the build, screenshot, lint, fix loop and tool cheat sheet), `papercraft-design` (design-quality rules) and `papercraft-critique`. [docs/agents.md](docs/agents.md) is the full guide: install, registering with agents, scripts, runs and review, safety. [docs/design-system.md](docs/design-system.md) covers variables, components and libraries.
|
||||
|
||||
## Get started
|
||||
|
||||
### Download
|
||||
|
||||
The macOS app (`papercraft-<version>-macos-<arch>.dmg`, containing PaperCraft.app) and the command-line tools (`papercraft-cli-<version>-macos-<arch>.zip`, with `papercraft-cli` and `papercraft-mcp`) are attached to each release of [masi/papercraft](https://git.marcospaulo.dev.br/masi/papercraft/releases). Builds are ad-hoc signed and not notarized, so on another Mac open the app with right-click › Open the first time.
|
||||
Releases are at [git.marcospaulo.dev.br/masi/papercraft/releases](https://git.marcospaulo.dev.br/masi/papercraft/releases).
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| macOS app (Apple silicon) | `papercraft-<version>-macos-aarch64.dmg`, containing PaperCraft.app |
|
||||
| Linux (x86_64) | `papercraft-<version>-linux-x86_64.tar.gz` |
|
||||
| CLI tools, macOS | `papercraft-cli-<version>-macos-aarch64.zip` (`papercraft-cli` and `papercraft-mcp`) |
|
||||
| CLI tools, Linux | `papercraft-cli-<version>-linux-x86_64.tar.gz` |
|
||||
|
||||
macOS builds are ad-hoc signed and not notarized: on another Mac, open the app with right-click, Open the first time. Other platforms: build from source.
|
||||
|
||||
### Build from source
|
||||
|
||||
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`). Install it with [rustup](https://rustup.rs).
|
||||
You need Rust 1.95 or newer (see `rust-version` in `Cargo.toml`); install it with [rustup](https://rustup.rs).
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/papercraft # projects home screen
|
||||
./target/release/papercraft --demo # sample planning board (not saved)
|
||||
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
|
||||
./target/release/papercraft board.papercraft # open a document
|
||||
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip (macOS)
|
||||
./target/release/papercraft # projects home screen
|
||||
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
|
||||
./target/release/papercraft --demo # sample board, not saved
|
||||
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip
|
||||
```
|
||||
|
||||
**macOS:** `xcode-select --install` for the linker, then the commands above. It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms.
|
||||
**macOS:** `xcode-select --install` for the linker. It renders with Metal through wgpu. **Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian or Ubuntu); it runs on X11 and Wayland through Vulkan, with GL as a fallback, and file dialogs use the XDG desktop portal.
|
||||
|
||||
**Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian/Ubuntu), then build. It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File dialogs use the XDG desktop portal.
|
||||
To run a test copy next to your own PaperCraft, give it its own folder and port: `PAPERCRAFT_HOME=/tmp/pc PAPERCRAFT_MCP_PORT=29998 ./target/release/papercraft`.
|
||||
|
||||
### Headless (servers, CI)
|
||||
|
||||
No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
|
||||
Headless rendering needs no display (wgpu renders offscreen, through Mesa lavapipe without a GPU):
|
||||
|
||||
```sh
|
||||
papercraft --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
|
||||
papercraft --screenshot home.png --home # projects home screen
|
||||
papercraft --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
|
||||
papercraft --export-svg out.svg PROJECT_DIR
|
||||
papercraft --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
|
||||
papercraft --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
|
||||
papercraft --import board.excalidraw # new project, prints its folder
|
||||
papercraft --screenshot ui.png --demo --size 1440x900 --scale 2
|
||||
papercraft --export-png out.png PROJECT_DIR --page 0 --scale 2
|
||||
papercraft --export-pdf out.pdf PROJECT_DIR --frames
|
||||
papercraft --import board.excalidraw # new project, prints its folder
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```sh
|
||||
cargo test # unit, scenario, headless GPU and CLI tests
|
||||
PAPERCRAFT_REQUIRE_GPU=1 cargo test # fail (instead of skip) when no GPU adapter exists
|
||||
cargo test --no-fail-fast
|
||||
PAPERCRAFT_REQUIRE_GPU=1 cargo test # fail instead of skip when there is no GPU adapter
|
||||
cargo test --release --test perf -- --nocapture # 10k-element frame timings
|
||||
```
|
||||
|
||||
The scenario tests drive the editor with synthetic pointer and keyboard events; `tests/mcp.rs` exercises every MCP tool on a headless session, then through an rmcp client over HTTP and through the `papercraft-mcp` stdio bridge. The SVG export is rasterized with resvg and compared with the GPU render.
|
||||
The scenario tests drive the editor with synthetic pointer and keyboard events; `tests/mcp.rs` exercises every MCP tool on a headless session, then over HTTP and through the stdio bridge. `cargo check --lib --target wasm32-unknown-unknown` covers the web build.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
⌘ on macOS, Ctrl on Linux and Windows. Press `?` or ⌘/ in the app for the full grouped list (100+ bindings), or ⌘K to search every command.
|
||||
⌘ on macOS, Ctrl on Linux. Press `?` or ⌘/ in the app for the full grouped list, or ⌘K to search every command.
|
||||
|
||||
| Keys | Action | Keys | Action |
|
||||
|---|---|---|---|
|
||||
| V / 1 | Select | ⌘K | Command palette |
|
||||
| H, or hold Space | Hand (pan) | ⌘Z / ⌘⇧Z | Undo / redo |
|
||||
| F or A | Frame | ⌘G / ⌘⇧G | Group / ungroup |
|
||||
| R / 2, O / 4, D / 3 | Rectangle, ellipse, diamond | ⌘⌥G | Frame selection |
|
||||
| ⇧Y, ⇧S | Polygon, star | ⌘⌥K / ⌘⌥B | Create component / detach |
|
||||
| R / 2, O / 4, D / 3 | Rectangle, ellipse, diamond | ⌘⌥G | Wrap selection in frame |
|
||||
| ⇧Y, ⇧S | Polygon, star | ⌘⌥K / ⌘⌥B | Create component / detach instance |
|
||||
| L / 6, ⇧L / 5 | Line, arrow | ⇧A / ⌥⇧A | Add / remove auto layout |
|
||||
| P, ⇧P / 7 | Pen, pencil | ⌥A ⌥H ⌥D / ⌥W ⌥V ⌥S | Align left/center/right, top/middle/bottom |
|
||||
| P, ⇧P / 7 | Pen, pencil | ⌥A ⌥H ⌥D / ⌥W ⌥V ⌥S | Align left, center, right / top, middle, bottom |
|
||||
| T / 8, S | Text, sticky note | ⌥⇧H / ⌥⇧V | Distribute spacing |
|
||||
| K | Laser pointer | ⌥⇧U S I E | Union, subtract, intersect, exclude |
|
||||
| E / 0 | Eraser | ⌘E | Flatten |
|
||||
| 9, ⌘⇧K | Place image | ⌘B / ⌘I / ⌘U / ⌘⇧X | Bold, italic, underline, strikethrough |
|
||||
| Enter / ⇧Enter | Edit (text, path) / select parent | ⌘⌥H | Hand-drawn style |
|
||||
| Enter / ⇧Enter | Edit / select parent | ⌘⌥H | Hand-drawn style |
|
||||
| Tab / ⇧Tab | Next / previous sibling | ⇧H / ⇧V | Flip |
|
||||
| ⌥-drag | Duplicate while dragging | ⌘⌥C / ⌘⌥V | Copy / paste properties |
|
||||
| ⌥ hover | Measure distances | ⌘R | Rename |
|
||||
| ⌘⌥⇧V | Variables | ⌘⌥C / ⌘⌥V | Copy / paste properties |
|
||||
| ⌘= / ⌘- / ⇧0 | Zoom in / out / 100% | ⇧1 / ⇧2 | Zoom to fit / selection |
|
||||
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
|
||||
| ⌘J | Design / Chat mode | ⌘L | Add selection to chat (Chat mode) |
|
||||
| N | Annotate tool | ⌘⇧A | Annotations: full, badges, hidden |
|
||||
|
||||
Draw a frame around existing shapes and it adopts them; shapes drawn inside a frame become its children, and dragging a layer into or out of a frame re-parents it. Hold ⌘/Ctrl while dragging to suspend snapping.
|
||||
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
|
||||
|
||||
## Status
|
||||
## Status and known limits
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PaperCraft is in early alpha.** It is usable for planning, sketching and building screens, but it is not a replacement for a mature design tool. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
|
||||
|
||||
Known limits:
|
||||
> PaperCraft is an alpha. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
|
||||
|
||||
* **No rotation.** Nodes are axis-aligned boxes; rotated imports are baked into paths.
|
||||
* **Text** is Inter only (regular, bold, italic, bold italic) with no shaping: no ligatures, complex scripts or RTL, and no font picker. IME preedit is not displayed (committed text works).
|
||||
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames don't clip their corners. Group opacity multiplies into children. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG export).
|
||||
* **Booleans are destructive**: the result is a new vector path with flattened curves.
|
||||
* **Variables are colors only** and apply to fills and text color; strokes and gradients keep fixed colors.
|
||||
* **`write_html`** ignores margin, grid, tables, shadows and transforms (it reports them as `ignoredStyles`). Remote `http(s)` images aren't fetched; pass a local path or a `data:` URI.
|
||||
* **MCP**: one project is open at a time, so `fileId` must name the open project. Moving or duplicating layers between pages isn't supported.
|
||||
* **Pen pressure.** winit 0.30 does not expose tablet pressure for pen events that arrive as mouse input, which is common with macOS and Wacom tablets. Otherwise it is simulated from stroke velocity.
|
||||
* **Linux file dialogs** need an XDG desktop portal. The headless CLI is unaffected.
|
||||
* **PDF** text is outlined (not selectable).
|
||||
* **Text** is Inter only, with no shaping (no ligatures, complex scripts or RTL) and no font picker. IME preedit is not displayed.
|
||||
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames do not clip their corners. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG and PDF export). There is no layer or background blur yet.
|
||||
* **Variables:** gradients keep fixed colors; there are no text styles and no component variants or properties.
|
||||
* **Booleans are destructive:** the result is a new vector path with flattened curves.
|
||||
* **`write_html`** ignores margin, tables and transforms (it reports them as `ignoredStyles`) and does not fetch remote images; pass a local path or a `data:` URI.
|
||||
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. The in-app Chat mode runs Claude Code, Codex, ACP agents or your own Anthropic key. Agents that are not in Chat connect over MCP or the CLI.
|
||||
* **Sharing** has no live presence (cursors, selections) yet.
|
||||
* **Pen pressure** is simulated from stroke velocity on macOS tablets, because winit 0.30 does not expose it for mouse-like pen events.
|
||||
* **Releases** cover macOS (Apple silicon) and Linux x86_64. Windows packaging scripts exist but are not part of releases.
|
||||
* **PDF** text is outlined, not selectable.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/geom.rs vectors, rects, camera
|
||||
src/vector.rs bezier paths: flatten, hit test, SVG data, shapes, dashes
|
||||
src/model/ Node / NodeKind / paints / Document arena
|
||||
src/history.rs transactions, undo/redo
|
||||
src/layout.rs auto layout, constraints, group bounds
|
||||
src/ink.rs stroke smoothing, pressure, outline
|
||||
src/text.rs rich text layout and glyph outlines
|
||||
src/project/ project store, manifest/page formats, migrations
|
||||
src/session.rs open project, autosave, thumbnails
|
||||
src/editor/ tools, structure (groups/components), vector ops, pages, text editing
|
||||
src/render/ draw ops → cached meshes → wgpu; headless capture
|
||||
src/io/ PNG, SVG, PDF, export scopes, Excalidraw + SVG import, clipboard
|
||||
src/mcp/ MCP server (rmcp): tools, HTML/CSS mapping, lint, HTTP service
|
||||
src/ui/ home screen, pages, layers, inspector, palette
|
||||
src/bin/ papercraft-cli (shell access) and papercraft-mcp (stdio bridge)
|
||||
skills/ agent skills: papercraft, papercraft-design
|
||||
analysis/ behaviour studies and design notes
|
||||
docs/ guides and headless screenshots produced by `cargo test`
|
||||
src/geom.rs vectors, rects, camera
|
||||
src/vector.rs bezier paths: flatten, hit test, SVG data, shapes, dashes
|
||||
src/model/ Node, paints, Document arena
|
||||
src/history.rs transactions, undo and redo
|
||||
src/layout.rs auto layout, constraints
|
||||
src/text.rs, ink.rs rich text layout, stroke smoothing
|
||||
src/project/ project store, manifest and page formats, migrations
|
||||
src/session.rs open project, autosave, thumbnails
|
||||
src/variables.rs collections, modes, groups, types, aliases, bindings
|
||||
src/libraries.rs publish, enable and update shared libraries
|
||||
src/design_system.rs design-system import and export
|
||||
src/versions.rs saved versions
|
||||
src/runs.rs agent runs, auto-versions, revert
|
||||
src/sync.rs, merge.rs relay file sync and page merge
|
||||
src/editor/ tools, structure, vector ops, pages, text editing, design bindings
|
||||
src/render/ draw ops, cached meshes, wgpu, headless capture
|
||||
src/io/ PNG, SVG, PDF, export, Figma, Excalidraw and SVG import
|
||||
src/mcp/ tools, HTML and CSS mapping, lint, diff, codegen, HTTP server
|
||||
src/cli.rs offline calls and the shell behind papercraft-cli
|
||||
src/ui/ home, panels, inspector, palette, Variables, Assets, Libraries, agent view
|
||||
src/lottie/ icon motion
|
||||
src/web/ browser build
|
||||
src/bin/ papercraft-cli, papercraft-mcp, papercraft-relay
|
||||
skills/ agent skills
|
||||
specs/ feature specs (agent runs, design libraries, offline CLI, ...)
|
||||
analysis/ behaviour studies and design notes
|
||||
docs/ guides and screenshots
|
||||
```
|
||||
|
||||
## Downloads
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Windows installer | `papercraft-<ver>-windows-<arch>.msi` | Per-machine installer (WiX) |
|
||||
| Windows portable | `papercraft-<ver>-windows-<arch>-portable.zip` | `papercraft.exe` + CLI and MCP bridge |
|
||||
| macOS app | `papercraft-<ver>-macos-<arch>.dmg` | PaperCraft.app, drag to Applications. Ad-hoc signed, not notarized |
|
||||
| Command-line tools | `papercraft-cli-<ver>-<platform>-<arch>.zip` | `papercraft-cli` and `papercraft-mcp` |
|
||||
|
||||
Releases: https://git.marcospaulo.dev.br/masi/papercraft/releases. Other platforms: build from source.
|
||||
|
||||
## License and credits
|
||||
|
||||
PaperCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
|
||||
Bundled fonts, icons and other assets keep their own open licenses; each one is listed with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Inter (Regular, Medium, SemiBold, Bold and italics) is © The Inter Project Authors under the SIL Open Font License 1.1 (`assets/fonts/OFL.txt`). A few algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw); they are listed with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
Bundled fonts, icons and other assets keep their own licenses, listed in [ATTRIBUTION.md](ATTRIBUTION.md); Inter is © The Inter Project Authors under the SIL Open Font License 1.1. Some algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw), with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
|
||||
The app icon is original artwork ([`assets/app-icon/`](assets/app-icon/README.md)).
|
||||
PaperCraft started as a fork of [storytold/paper-rs](https://git.marcospaulo.dev.br/storytold/paper-rs); thanks to its contributors.
|
||||
|
||||
### Reverse-engineered origin
|
||||
|
||||
PaperCraft is an independent, clean-room-style reimplementation. It was reverse engineered from [Paper](https://paper.design) by studying the behavior of the Paper desktop app (`analysis/PAPER_ARCHITECTURE.md`) with [REA](https://github.com/morluto/rea) and Claude Code; Figma and Excalidraw were studied from their public docs and, for Excalidraw, its MIT source (`analysis/`). Only *behavior* was studied: no Paper or Figma code, shaders, icons, fonts or assets are used or committed. PaperCraft is not affiliated with, sponsored by or endorsed by Paper or Figma; those names are used only to describe the workflows it is compatible with.
|
||||
|
||||
<sub>Photoshop, Illustrator and Adobe are trademarks of Adobe Inc.; Figma is a trademark of Figma, Inc.; Paper is a product of its respective owners. PaperCraft is an independent, open-source project.</sub>
|
||||
PaperCraft is not affiliated with Paper or Figma. Only the behavior of other tools was studied (see `analysis/`); no code, shaders, icons, fonts or assets from them are used.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
PaperCraft is an early alpha: a local-first infinite-canvas design app that agents can drive. This
|
||||
file lists what is still missing, in priority order, with an honest status. Done items are checked
|
||||
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 15, 23, 25, 32, 34, 39, 40, 45 and section 6 were updated 2026-10-21. Milestone history is in
|
||||
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 7, 8, 12, 15, 23, 25, 32, 34, 39, 40, 44, 45, 46 and section 6 were updated 2026-10-21; rows 15, 47 and 48 on 2026-10-22. Milestone history is in
|
||||
[PROGRESS.md](PROGRESS.md); known rendering and import limits are in the README.
|
||||
|
||||
Status: **done** (works today), **partial** (some of it works), **todo**.
|
||||
@@ -19,12 +19,12 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
| 4 | Lint auto-fix | **done** | `lint_design {fix: true}` and UI `Fix` / `Fix all`: snaps spacing to 4px grid, snaps type scale, enforces 44px targets, links matching colors to variables. |
|
||||
| 5 | Lint in the UI | **done** | Live Lint section in Design tab; click issue to select & zoom, or click `Fix` / `Fix all`. |
|
||||
| 6 | Variable modes | **done** | Mode selector + create/delete modes in Design tab and via `set_variables {mode}` / `set_variable_mode`. |
|
||||
| 7 | Number variables (spacing, radius, font size) | partial | Non-color tokens (`--spacing-md`) editable in Design tab and substituted in CSS; typed per-field bindings todo. |
|
||||
| 8 | Stroke and gradient variables | todo | Strokes and gradients keep fixed colors. |
|
||||
| 7 | Number variables (spacing, radius, font size) | **done** | Typed number, string and boolean variables in collections with groups and aliases; fields bind (size, gap, padding, radius, font size, line height, opacity, visible) in the inspector and `bind_variable`. |
|
||||
| 8 | Stroke and gradient variables | partial | Strokes and text color bind to variables. Gradients keep fixed colors. |
|
||||
| 9 | Text styles | todo | Named type styles (Heading / L, Body / M) that update every text layer using them. |
|
||||
| 10 | Component variants and properties | todo | Variants are separate components named `Button / Primary`. No variant sets, boolean or text properties. |
|
||||
| 11 | Swap instance, detach by tool | **done** | `swap_instance` and `detach_instance` MCP tools + UI controls in Design tab and Inspector. |
|
||||
| 12 | Cross-page and shared libraries | todo | Components and instances must live on one page. |
|
||||
| 12 | Cross-page and shared libraries | **done** | Instances of a main on any page; publish a project as a library, enable, update and disable it in other projects (Libraries modal, `*_library` tools). |
|
||||
| 13 | Export design tokens | **done** | `get_variables` with `format: "css"`, `"json"` (W3C DTCG + modes), or `"tailwind"` (`@theme`), plus Copy CSS / Copy JSON in Design tab. |
|
||||
| 14 | Apply a design system to existing work | partial | `lint_design {fix: true}` links matching literal colors to variables and snaps spacing/type scale. |
|
||||
|
||||
@@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 15 | MCP and CLI tool surface | **done** | 53 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 15 | MCP and CLI tool surface | **done** | 67 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
|
||||
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
|
||||
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
|
||||
@@ -40,6 +40,7 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
| 20 | Screenshot diffing | **done** | `diff_frames` tool (`src/mcp/diff.rs`) for structural/property frame diffing + `diff_rgba` pixel diffing with highlight PNG. |
|
||||
| 21 | Moving nodes between pages | todo | `move_nodes` stays on one page. |
|
||||
| 22 | Remote images in `write_html` | todo | Only local paths and `data:` URIs. |
|
||||
| 46 | Design from the CLI without the app | **done** | `papercraft-cli call --offline` and `shell` run the tools in-process on the project store; screenshots use a GPU or the software adapter. |
|
||||
|
||||
## 3. Design features
|
||||
|
||||
@@ -83,5 +84,12 @@ Spec: [specs/agent-runs/spec.md](specs/agent-runs/spec.md), tasks: [specs/agent-
|
||||
| 41 | Agent presence on the canvas | todo | Outline the node a tool call touches, label it, pause or stop the run. |
|
||||
| 42 | `get_context` tool | todo | Selection, variables, nearby components and lint summary in one call, so an agent orients in one round trip. |
|
||||
| 43 | Comments assigned to agents | todo | `@agent` in a thread triggers an edit and a reply. Needs threaded comments. |
|
||||
| 44 | In-app prompt bar | todo | Run a prompt on the selection through the user's own key or local CLI. Largest scope: provider config, key storage, cost. |
|
||||
| 44 | In-app prompt bar | **done** | Chat mode: Claude Code, Codex or your own Anthropic key; see `docs/chat.md`. |
|
||||
| 45 | Correct tool annotations | **done** | `lint_design` no longer sends `readOnlyHint`; `fix: true` edits are classified. |
|
||||
|
||||
## 7. Annotations and chat agents
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 47 | Design annotations | **done** | Notes with leader lines and categories, templates per project and page, view modes (⌘⇧A), Annotate tool (N), side view, inspector section, six MCP tools; see `docs/annotations.md`. |
|
||||
| 48 | ACP agents in Chat | **done** | Any ACP agent on the PATH (OpenCode works; Gemini CLI refuses ACP clients for now; Cursor needs `cursor-agent login`), npx agents after enabling, custom agents; see `docs/chat.md`. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Driving PaperCraft with agents
|
||||
|
||||
PaperCraft exposes one set of 53 tools three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
|
||||
PaperCraft exposes one set of 67 tools three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
|
||||
|
||||
| Way | Use it when | Needs the app running |
|
||||
|---|---|---|
|
||||
@@ -8,6 +8,8 @@ PaperCraft exposes one set of 53 tools three ways. They all run the same code in
|
||||
| MCP over stdio, `papercraft-mcp` | the client only starts stdio servers | for calls; `tools/list` works offline |
|
||||
| Shell, `papercraft-cli` | the agent has a terminal and you want no tool schemas in its context | for `call`; `list`, `schema` and `icons` work offline |
|
||||
|
||||
If you would rather talk to the agent inside the app, Chat mode (⌘J, or `Design | Chat` in the top bar) is the in-app alternative. It runs Claude Code or Codex through their CLIs against the same MCP server, ACP agents such as OpenCode or Gemini CLI, or the Anthropic API with your own key, and keeps the conversation next to the canvas, with the same runs, review and revert. The setup and the thread are in [chat.md](chat.md). The rest of this guide covers driving the app from your own terminal or agent client.
|
||||
|
||||
## Install
|
||||
|
||||
1. Get the app and the tools: the DMG and the `papercraft-cli` zip from the [releases](https://git.marcospaulo.dev.br/masi/papercraft/releases), or `cargo build --release` (binaries `papercraft`, `papercraft-cli`, `papercraft-mcp` in `target/release/`).
|
||||
@@ -75,6 +77,51 @@ papercraft-cli call finish_working_on_nodes '{"nodeIds":["abc123:1"]}'
|
||||
|
||||
Pass JSON inline, or `-` to read it from stdin (best for HTML, which is full of quotes; `jq -Rs` can wrap a file into the right JSON). Text results go to stdout. Images are saved to `--out` (default `<tool>.png`) and the path is printed.
|
||||
|
||||
## Without the app
|
||||
|
||||
`papercraft-cli` also works with PaperCraft closed (a server, CI, a headless box). When nothing answers on the port, or with `--offline`, tools run inside the CLI on the project store (`$PAPERCRAFT_HOME`, else the default folder) and every editing call is saved. Projects made this way open normally in the app.
|
||||
|
||||
```sh
|
||||
papercraft-cli call create_file '{"name":"Demo"}' # -> fileId
|
||||
papercraft-cli call create_artboard '{"name":"Card"}' --project Demo
|
||||
papercraft-cli call get_screenshot '{"nodeId":"abc123:1"}' --project Demo -o card.png
|
||||
```
|
||||
|
||||
`--project <name|id|path>` opens that project first; without it the most recently modified one is used. With the app open on a project, plain `call` goes to the app, and `--offline` on that same project is refused ("PaperCraft has this project open") so two writers never share a folder. Screenshots need a GPU adapter; with none, the software adapter is tried, and if that fails the image tools return an error while all other tools work.
|
||||
|
||||
### Shell and scripts
|
||||
|
||||
`papercraft-cli shell [--project X] [--offline]` keeps one session open (offline, one in-process project; otherwise the app). A line is `<tool> <json>` (JSON may span lines until its braces close) or a command: `:help :tools :schema <tool> :open <project> :pages :page <name|id> :shot [nodeId] [file] :undo :redo :save :watch on|off :quit`. `:watch on` renders the artboard last edited to `preview.png` after each editing call, shown inline on iTerm2, WezTerm and kitty. Input is plain stdin (no line editing or history; use `rlwrap` for that). Piped input prints no prompt and exits 1 if any line failed, listing the line numbers on stderr.
|
||||
|
||||
```sh
|
||||
papercraft-cli shell --offline --project Demo <<'EOF'
|
||||
# build a card
|
||||
create_artboard {"name":"Card","styles":{"width":"320px","height":"180px","backgroundColor":"#1e1e2e"}}
|
||||
write_html {
|
||||
"targetNodeId": "abc123:1",
|
||||
"mode": "insert-children",
|
||||
"html": "<div style='font-size:28px;color:#cdd6f4'>Hello</div>"
|
||||
}
|
||||
:shot abc123:1 card.png
|
||||
EOF
|
||||
```
|
||||
|
||||
## Design-system tools
|
||||
|
||||
* Variables: `get_variables` and `set_variables` take collections, groups (`color/brand/primary`), types (`color`, `number`, `string`, `boolean`), `mode`, `alias`, `description` and `hidden`; the old `{"name":"#hex"}` call still works and lands in the first collection. `create_collection` makes a collection with modes, `delete_variables` removes by name (refuses aliased ones unless `force`), `bind_variable` binds or detaches a field of layers (`stroke`, `text-color`, `width`, `height`, `gap`, `padding`, `radius`, `font-size`, `line-height`, `opacity`, `visible`).
|
||||
* Components: `get_components` lists mains of every page and enabled library with a `componentKey`; `create_instance` and `swap_instance` accept a main from any page or library (`componentId` or `componentKey`).
|
||||
* Libraries: `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`. They work offline too, on the project store.
|
||||
|
||||
## Annotation tools
|
||||
|
||||
* `annotate {pageId?, notes: [{node, text, type?}]}` adds many notes in one undo step. `node` is a node id or an exact layer name; the first line of `text` is the label and the rest the body; `type` is a category key (default `rule`) that the page's template must enable. Placement is automatic.
|
||||
* `get_annotation_template {pageId?}` returns the allowed types with their guidance and fields. Call it once before `annotate`.
|
||||
* `set_annotation_template {template, scope?: "page" | "project", pageId?}` switches the template by id or name.
|
||||
* `get_annotations {nodeId?, pageId?, full?}` reads notes as `[number, node, type, text]` rows.
|
||||
* `update_annotation {id | ref, text?, type?}` and `delete_annotations {ids}` take ids or references such as `Sign in #3`.
|
||||
|
||||
See [annotations.md](annotations.md) for categories, templates and what the notes look like.
|
||||
|
||||
## Safety
|
||||
|
||||
The server listens on loopback only, rejects requests that carry an `Origin` header (browsers) and foreign `Host` headers. Anything on your machine that can reach `127.0.0.1:29980` can edit the open project, so do not forward the port. Undo (⌘Z, or the `undo` tool) reverts any agent edit.
|
||||
@@ -88,6 +135,6 @@ The Agent view lists runs and lets you review changes, revert a whole run, undo
|
||||
## Reference
|
||||
|
||||
* [Tool reference](../skills/papercraft/references/tools.md), generated from `papercraft-cli schema`.
|
||||
* [Design systems](design-system.md): variables, components, icons, lint.
|
||||
* [Design systems](design-system.md): collections, variables and bindings, components, libraries, icons, lint.
|
||||
* [Design-system packages](design-system-packages.md): importing and exporting an OpenDesign package.
|
||||
* `papercraft-cli --help`-style usage: run `papercraft-cli` with no arguments.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Annotations
|
||||
|
||||
An annotation is a note that explains a design in place: a business rule, a behaviour, where the data comes from, an accessibility gap. Each note is a small card with a number, a category, a label and an optional body, joined to its layer by a leader line. Teams use them for review and handoff, and turn them off for presenting.
|
||||
|
||||
Annotations live on a page and are saved with it (the `annotations` list in the page file). Pages without any load and save exactly as before. Categories and templates are project-wide and live in `design.json`.
|
||||
|
||||
## Categories
|
||||
|
||||
Every note has one category. The category is the only thing that sets how a note looks: its icon, colour and badge come from the category, and a note stores none of them. Change a category's colour or icon and every note in it changes.
|
||||
|
||||
Each category has a short type key. Agents and the Annotate popover use the key; the UI shows the name.
|
||||
|
||||
| Key | Name | Icon | Colour | Use for |
|
||||
|---|---|---|---|---|
|
||||
| `rule` | Business rule | scale | violet `#7c5cff` | the rule or constraint the part enforces |
|
||||
| `behaviour` | Behaviour | zap | blue `#2f80ed` | states, transitions, edge cases |
|
||||
| `content` | Content | type | green `#1fa87a` | copy, tone, length |
|
||||
| `data` | Data | database | amber `#e08a1e` | where a value comes from |
|
||||
| `a11y` | Accessibility | accessibility | pink `#d94f8a` | focus, labels, contrast, reading order |
|
||||
| `dev` | Dev note | code | grey `#6b7280` | implementation notes for engineers |
|
||||
|
||||
You can add categories, rename them, change their key, colour and icon, and write guidance for each one in the Customize modal. A custom category gets a key too.
|
||||
|
||||
## Templates
|
||||
|
||||
A template says which kinds of notes a page wants, and what each note should contain. The project has a default template; a page can use its own instead.
|
||||
|
||||
Built-in templates are editable copies, not locked. Each category in a template has enabled or not, guidance (a short prompt for what the note should say) and optional fields (structured lines such as `Owner`).
|
||||
|
||||
| Template | Categories | Fields |
|
||||
|---|---|---|
|
||||
| Product spec | rule, behaviour, content, data | none |
|
||||
| Dev handoff | behaviour, data, dev, a11y | none |
|
||||
| Accessibility review | a11y | Issue, WCAG, Fix |
|
||||
| Content review | content | Tone, Max length |
|
||||
| Minimal | rule | none |
|
||||
|
||||
Pick the project default or "This page" from the template chips at the top of the Annotations side view. Built-ins can be reset; custom templates can be deleted. Duplicate copies any template so you can change it.
|
||||
|
||||
**Customize…** (side view) opens the template editor: name, display (full notes or badges), the enabled categories, the guidance and fields of each. Changes to categories apply to the whole project.
|
||||
|
||||
The Annotate popover offers only the enabled categories of the active template, and shows each field as a labelled line. A field is stored as a `Name: value` line at the top of the note's body. Agents get the same rule: `annotate` refuses a type the template disables and lists the allowed keys.
|
||||
|
||||
## View modes
|
||||
|
||||
Three levels, all view state. They are not saved in the design and not undoable.
|
||||
|
||||
* **Full**: every note shows its label and the first lines of its body.
|
||||
* **Badges**: only the number badges are shown.
|
||||
* **Hidden**: nothing is drawn.
|
||||
|
||||
`⌘⇧A` (View → Annotations) cycles Full → Badges → Hidden → Full. The starting mode follows the template's display setting until you change it.
|
||||
|
||||
Two finer controls:
|
||||
|
||||
* **Per note**: collapse a note to its number badge; click the badge to expand it again.
|
||||
* **Per category**: show or hide a category from the category filter in the side view.
|
||||
|
||||
Annotations are drawn as an overlay on the canvas only. Exports (PNG, SVG, PDF) and Present mode never include them. There is no option to include them in an export yet.
|
||||
|
||||
## Annotate tool
|
||||
|
||||
Press `N` for the Annotate tool, then click a layer. The New annotation popover opens beside it with:
|
||||
|
||||
* Category, from the template's enabled categories.
|
||||
* Label, up to 40 characters. The popover shows the count.
|
||||
* The template's fields, if any.
|
||||
* A note, in Markdown.
|
||||
|
||||
Save creates the note with its leader line to the layer's side; Cancel discards it. You can also select layers, right-click, and choose **Annotate**.
|
||||
|
||||
Each note's leader line starts at the note card and ends at the anchor on the target. The card and the anchor are stored relative to the target, so moving or resizing the layer keeps the note attached. Drag a card to move it; the line re-routes.
|
||||
|
||||
If the target is deleted, the note stays where it was in the side view's **Detached** group, with a warning badge.
|
||||
|
||||
## Annotations side view
|
||||
|
||||
The side view lists the page's notes:
|
||||
|
||||
* Grouped by top-level frame, plus **Page** (notes on no frame) and **Detached**.
|
||||
* Each row shows the number, the category chip and the label. Click a row to select the layer and zoom to it.
|
||||
* The category filter shows or hides categories.
|
||||
* Edit a note inline, or delete it. Edits are one undo step each.
|
||||
* **Add annotation** opens the popover for the current selection.
|
||||
* **Annotate with AI** puts a prompt in the Chat composer, unsent, with the selected frame (or the page) as a chip. The prompt asks the agent to read the template with `get_annotation_template` and add all the notes in one `annotate` call. It needs a saved project and Chat mode.
|
||||
|
||||
Numbers run per frame in reading order (top to bottom, then left to right), so a frame reads 1, 2, 3. Notes on no frame are numbered after that, from 1.
|
||||
|
||||
## Inspector
|
||||
|
||||
When a single layer is selected, the inspector has an Annotations section. It lists that layer's notes, with edit and delete, and an Add annotation button. The note's anchor is one of the four sides of the target (Top, Right, Bottom, Left) with a position along it.
|
||||
|
||||
## Agents
|
||||
|
||||
Agents work with notes through six MCP tools, all in `src/mcp/tools.rs`. They are one undo step per call and appear in `edits_doc`, so a run that only adds notes still gets a Review card (a "Notes" group, counted as added, changed or removed), and Revert removes them.
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `get_annotation_template` | Reads the template: `[type key, guidance, field names]` for each enabled type. Call it once before `annotate`. |
|
||||
| `set_annotation_template` | Switches the template by id or name, for one page (default) or the project (`scope: "project"`). |
|
||||
| `annotate` | Adds many notes at once. Each note names a node id or an exact layer name on the page, a `text` and a `type` (default `rule`). |
|
||||
| `get_annotations` | Reads notes as `[number, nodeName, type, text]` rows (text capped at 120 characters). `full: true` returns complete objects; `nodeId` limits to a subtree; `pageId` picks another page. |
|
||||
| `update_annotation` | Changes the text or type of one note, by `id` or by reference such as `Sign in #3`. |
|
||||
| `delete_annotations` | Deletes notes by ids or references such as `Sign in #3`. |
|
||||
|
||||
For `annotate` and `update_annotation`, the first line of `text` is the label and the rest is the body. There are no positions, icons or colours: PaperCraft places each note beside its target, outside the frame, stacked so none overlap. `annotate` is all or nothing: if one note is refused (an unknown layer, or a type the template disables), none is added and the error lists the allowed keys.
|
||||
|
||||
Batched call, on a page whose template is Product spec:
|
||||
|
||||
```json
|
||||
{
|
||||
"notes": [
|
||||
{"node": "Sign in", "type": "rule", "text": "Lock after 5 failed tries\nLocks for 15 minutes, then resets."},
|
||||
{"node": "Email field", "type": "data", "text": "Source: users.email\nValidated on blur"},
|
||||
{"node": "Submit", "type": "behaviour", "text": "Disabled until both fields are valid\nSpinner while the request runs"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The reply is one row per note, `[number, node, type, label]`. Then `get_annotations {}` reads them back, and `delete_annotations {"ids":["Sign in #3"]}` removes one by its frame reference.
|
||||
|
||||
**Handoff.** `get_design_spec` and the code exports (`get_jsx`) include the notes of the frame as comments. Each note is one line, `// 1 Business rule: Lock after 5 failed tries`, with its body lines as more `//` lines, so code generated from a frame carries the rules next to the markup.
|
||||
|
||||
## Storage
|
||||
|
||||
* The page file has an `annotations` list. Each note holds its target (or a point, when it has none), its anchor, label, body, category, position, author (`user` or `agent`) and creation time. A note whose target is gone is marked `detached`.
|
||||
* `design.json` holds the categories and the templates, with the project default. A page may name its own template.
|
||||
* Old projects load unchanged. A page without annotations saves byte-identical to before.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Chat mode
|
||||
|
||||
Chat mode puts a conversation with an agent next to the canvas. Press ⌘J, or use the `Design | Chat` switch in the top bar, to turn it on. The chat column sits on the left (420 to 640 px, 480 by default, resizable) and the live canvas fills the rest. The layers and inspector are hidden; the inspector can be opened as a narrow overlay on the canvas's right edge. Design mode works as before, and a chat keeps running in the background when you switch back.
|
||||
|
||||
The mode is remembered per project. Entering a project that was last left in Chat mode opens in Chat mode and shows its newest chat.
|
||||
|
||||
Chat mode is native only. The web build shows the switch disabled with a note.
|
||||
|
||||
## Backends
|
||||
|
||||
Chat drives the same tools as an agent connected over MCP, so the edits, runs, undo and revert behave the same whichever backend you use. There are three ways in: two CLIs that PaperCraft talks to directly, any agent that speaks ACP, and API endpoints.
|
||||
|
||||
**Claude Code** runs `claude -p` with streaming JSON in and out. PaperCraft writes an MCP config that points the CLI at the app's own server on `127.0.0.1`, allows the `mcp__papercraft__*` tools, and passes `--strict-mcp-config`, so the CLI sees only PaperCraft's server. The PaperCraft skill text goes in through `--append-system-prompt-file`. The working directory is the project folder. Follow-up messages resume the CLI session by its id, so the agent keeps its context.
|
||||
|
||||
**Codex** runs `codex exec --json` in the project folder. The PaperCraft server is added with a `-c mcp_servers.papercraft.url=...` override, and its tools are pre-approved (`default_tools_approval_mode="approve"`), so Codex does not stop to ask for each one. The PaperCraft instructions are prepended to the first prompt of a chat; resumed sessions do not repeat them. Follow-ups resume the Codex session the same way.
|
||||
|
||||
**ACP agents** run any agent that speaks the Agent Client Protocol (https://agentclientprotocol.com) over stdio. PaperCraft starts the agent once per turn, sends `initialize` and `session/new` with the project folder as the working directory, and passes PaperCraft's MCP server: over HTTP when the agent supports it, otherwise through the bundled `papercraft-mcp` stdio bridge. The prompt goes out as `session/prompt`, and the agent's streamed updates become the same thread as the other backends: text, thinking, tool cards and the plan. Stop sends `session/cancel`. Follow-ups resume with `session/load` when the agent supports it. ACP has no system prompt field, so the PaperCraft instructions go in front of the first prompt.
|
||||
|
||||
The ACP agents PaperCraft knows about, and what was checked on the development Mac:
|
||||
|
||||
* **OpenCode** (`opencode acp`): works.
|
||||
* **Gemini CLI** (`gemini --acp`): found and started, but its server currently refuses ACP clients, so turns fail with the agent's error until Google changes that.
|
||||
* **Cursor** (`cursor-agent acp`): sign in first with `cursor-agent login`.
|
||||
* **Pi** (`pi-acp`, an adapter run through `npx`): enable it once in the agent panel or Settings, then it works like the others.
|
||||
* **Goose, Kimi, Devin, Junie, Mistral Vibe, Hermes, OpenHands, Stakpak, VT Code, Poolside, Droid**: started with the command their own docs give. They are not all verified here.
|
||||
* **Claude Code and Codex ACP adapters** (`npx`): listed under Enable via npx. Claude Code and Codex themselves use the direct backends above.
|
||||
|
||||
Agents that run through `npx` are off until you enable them, because enabling runs code that npx fetches from npm.
|
||||
|
||||
ACP permissions: PaperCraft's own tools run without asking, except the destructive ones listed under Approval below. Any other tool the agent asks to run (its own shell or file tools, for example) shows an approval card with Approve or Deny in the thread, and Deny is sent back to the agent.
|
||||
|
||||
**Custom agents** are your own ACP agents: a name, a command (a program name on the resolved `PATH`, or a full path) and arguments. The arguments are split on spaces, with no quoting. Custom agents appear under Custom in the agent panel and in Settings → Chat.
|
||||
|
||||
**API endpoints** call a model over HTTPS with your own key. Settings → Chat holds a list of endpoints, each with a name, a format, a base URL, a model and an optional key. The app's tool list is sent as the API tools, and every tool call runs through the same code path as an MCP call on the UI thread. The loop continues until the model ends its turn, for up to 40 tool rounds per message.
|
||||
|
||||
* **Format**: `Anthropic` is the Messages API (`POST {base}/v1/messages`, `x-api-key`). `OpenAI` is Chat Completions (`POST {base}/chat/completions`, `Authorization: Bearer`, streaming `choices[].delta`, tools as `{"type":"function",...}`, tool results as `tool` messages). Both give the same thread: text, tool cards, approvals, Stop, the usage line.
|
||||
* **Base URL and model** are free text, for example `https://api.anthropic.com`, any Anthropic-compatible URL, or `http://127.0.0.1:11434/v1`. The key is optional, so local servers need none.
|
||||
* A default endpoint named `Anthropic` (`https://api.anthropic.com`, `claude-opus-5-5`) exists from the start, so the first run needs only a key.
|
||||
* The agent panel lists Detected agents, API endpoints and Custom agents (see Setup). Each chat remembers its backend.
|
||||
* **Claude-only fields** (effort, the thinking summary, the refusal fallback and its beta header, eager tool-input streaming, prompt-cache breakpoints) are sent only when the base URL host is `api.anthropic.com`. For that host the model menu offers `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5`, effort is `medium` by default (`low` and `high` in the menu), and the Thinking row shows the summary. Every other endpoint gets a plain request. Missing `usage`, thinking or cache fields in the reply are fine.
|
||||
* **OpenAI format and images**: attached images are sent as `image_url` data URIs. A tool message in this format carries text only, so a tool result with an image (such as `get_screenshot`) goes back as the text `[image omitted]`, and the image follows as an extra user message in the same round.
|
||||
* **Usage line**: input, output and cached tokens, as the server reports them; nothing when it reports none. A cost estimate shows only for the Claude models in the small price table in `src/chat/endpoint.rs`. It is not a bill.
|
||||
* `ANTHROPIC_API_KEY` in the environment is used for `api.anthropic.com` when no key is stored. `PAPERCRAFT_ANTHROPIC_BASE_URL` points Anthropic-format requests at another URL (for tests and proxies).
|
||||
|
||||
## Setup
|
||||
|
||||
### Finding agents
|
||||
|
||||
Chat finds agents the way your terminal does. At startup PaperCraft reads your login shell's `PATH` once, off the UI thread (`$SHELL -l`, 3 s limit), and merges it with the app's own `PATH` and the usual folders (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `~/.cargo/bin`, `~/.bun/bin`, `~/.npm-global/bin`, Volta and nvm). Detection and the agent processes both use that merged `PATH`. This is why an agent installed under `~/.local/bin` or Homebrew is found even when PaperCraft was opened from the Dock.
|
||||
|
||||
A row shows a version when the agent reports one. A program with the right name that is not the agent (for example the `goose` database migration tool, which shares its name with Block's Goose agent) shows `Wrong program` with its path, not a false Ready.
|
||||
|
||||
PaperCraft never shows install commands. An agent that is not found shows `Not found`, with `Set path…` (opens Settings) and `Docs` (the agent's own documentation). Install the agent with its own installer, then press Detect again.
|
||||
|
||||
### Agent panel
|
||||
|
||||
Click the agent chip in the chat header. The panel opens over the chat column with a shadow and a dimmed backdrop, sized like the column. It has a search field (name or id), and these sections:
|
||||
|
||||
* **Detected**: agents found on your `PATH`, with the version and path. Click one to use it for this chat.
|
||||
* **Enable via npx**: agents that start through `npx` (Pi, Qwen Code, Kilo, Cline, Auggie, Copilot CLI, Qoder, CodeBuddy and the Claude Code and Codex ACP adapters). Each row has an `Enable` chip. The section says that it runs code fetched by npx.
|
||||
* **Not found**: registry agents that are not on this computer, folded by default.
|
||||
* **API endpoints**: your endpoints, with an `Add` action that opens Settings.
|
||||
* **Custom**: `Add custom agent` opens Settings.
|
||||
|
||||
Each row has a status chip: `Ready`, `Enable`, `Not found`, `Wrong program`, `No key` (an endpoint without a key) or `Set up` (an endpoint without a URL or model). Arrow keys move through the rows, Enter picks one, Escape closes the panel.
|
||||
|
||||
Settings → Chat has the same list, so an agent can also be hidden from the panel there (the `Show in the chat agent picker` switch on its row).
|
||||
|
||||
### Setup card
|
||||
|
||||
A chat with no backend chosen shows one card, "Connect an agent". It lists the agents detected on this computer, up to four, with `Show all agents` for the rest. Below them are `Add an API endpoint` and `Add custom agent`. The card never lists install steps.
|
||||
|
||||
### Sign-in
|
||||
|
||||
When an agent says it needs you to sign in (ACP's "Authentication required" and similar messages), the failed turn shows "<Agent> needs you to sign in" with the command from the agent's docs, then Retry. For Cursor that is `cursor-agent login`, for OpenCode `opencode auth login`, for Codex `codex login`, for Claude Code's ACP adapter `claude /login`. For agents without a known command the card says to sign in with the agent's own app and retry. Chat does not sign in for you.
|
||||
|
||||
### Settings → Chat
|
||||
|
||||
The page scrolls and keeps one grid for its labels. Its sections:
|
||||
|
||||
* **Agents**: one row per agent, with its status, version and path. A details disclosure holds the **Path override** (for a binary that is not on `PATH`, or a specific install; empty means the first match on the merged `PATH`), the **Arguments override** (replaces the registry's ACP arguments, for example `--acp --debug`), and for npx agents the **Enable** button. The **Show in the chat agent picker** switch hides an agent from the panel. Custom agents have Name, Command and Arguments fields and a Remove button. `Detect again` re-runs detection.
|
||||
* **API endpoints**: add, edit and remove endpoints (name, format, base URL, model) and set the default one. Each has a key field with Save and Remove. Keys are stored in the macOS Keychain, Windows Credential Manager or the Linux Secret Service, under the service name `PaperCraft` and the user `api-key:<base URL>`. An older single key (`anthropic-api-key`) is still read for `api.anthropic.com` and moves to the new entry the next time you save it. Removing a key makes that endpoint stop until a key is saved again (for `api.anthropic.com`; other endpoints work without one).
|
||||
* **Defaults for new chats**: the backend, the endpoint, and the effort for `api.anthropic.com`.
|
||||
|
||||
Settings from before endpoints existed (`api_model`) move into the default endpoint on first load. The `claude_path` and `codex_path` settings from earlier versions still win for their agent until you save the Agents list. These defaults, the agent settings and the endpoint list (never the keys) are saved in `chat_settings.json` in your config folder (`~/Library/Application Support/papercraft/` on macOS). They are not part of any project.
|
||||
|
||||
## The thread
|
||||
|
||||
The header has the chat title (rename), the chat list (new chat, switch, delete), the agent chip (for example `Claude Code · Opus 5.5`) and the context meter. The meter shows the tokens in the context at the last model call against a 200k window, and turns amber past 75%. It is hidden for Codex, which reports no per-call usage.
|
||||
|
||||
Replies are markdown: paragraphs, lists, code blocks with a copy button, inline code and links. While a turn runs, the reply streams in.
|
||||
|
||||
* **Thinking**: when the backend reports reasoning, a row shows "Thinking…" while it runs and collapses to a summary when it ends. Expand it to read the text.
|
||||
* **Tool cards**: one card per tool call with the verb and target (for example "Drawing layout · Card"), a spinner while it runs, and a failed state with the error. Args and result are collapsible. `get_screenshot` results show a thumbnail.
|
||||
* **Step groups**: consecutive tool calls in one reply fold into one line ("Built the card · 5 steps") that expands to the cards.
|
||||
* **Plan**: when the agent keeps a todo list (Claude Code's `TodoWrite`, or an ACP plan), the latest list is pinned above the composer with a "done of total" count.
|
||||
* **Approval**: on the API backend and for ACP agents, the destructive tools (`delete_pages`, `disable_library`, `import_design_system`, and `delete_nodes` on more than 20 layers) wait for Approve or Deny before they run. Deny returns an error to the model, which can then carry on. For ACP agents, any non-PaperCraft tool the agent asks for shows the same card. The Claude Code and Codex CLIs do not show this card. They use the CLI's own permission rules, and in non-interactive mode a tool that needs permission is denied. The papercraft tools are pre-allowed on both CLIs.
|
||||
* **Checkpoint**: when an agent run starts, the thread shows "Checkpoint · before run N". "Restore to here" reverts that run and every run after it, through the same revert as the Agent view.
|
||||
* **Run card**: when a run ends, "Agent finished, N changes" with Review, Revert and Accept. These are the same actions as the run review on the canvas.
|
||||
* **Errors**: a failed turn shows a red row with Retry. A sign-in problem shows the sign-in card (see Setup).
|
||||
* **Canvas follow**: when a tool call targets a node, the canvas pans to it, unless you moved the camera in the last 10 seconds.
|
||||
|
||||
## Composer and attachments
|
||||
|
||||
Type in the composer, ⏎ sends and ⇧⏎ adds a line. The empty state offers suggestions built from the project, such as "Design a sign-in screen using this project's variables" or "Lint this page and fix warnings".
|
||||
|
||||
* **Selection**: the `@ Selection` chip adds the current selection to the message as a chip (also ⌘L). Each click adds another chip, labelled with the layer name or `<first> · N layers`; an identical selection is added once, and each chip is removed on its own. Every chip is sent as a short line with the layer name and node ids.
|
||||
* **Start failures**: if a turn cannot start (agent missing, bad path), an error row with Retry shows in the thread and your text stays in the composer.
|
||||
* **Images**: the image button opens a file picker. You can also paste a clipboard image or drop image files on the column (PNG, JPEG, GIF, WebP).
|
||||
|
||||
For the API backend, images are sent as image blocks, so the model sees them. For the CLIs and ACP agents, each image is saved under `.papercraft-chat-tmp/` in the project and its path is added to the prompt for the agent to read.
|
||||
|
||||
While a turn runs, the send button becomes **Stop**. Stop asks the backend to interrupt: the CLIs get an interrupt, or SIGINT, then a kill after 3 seconds; ACP agents get `session/cancel`, then a kill if they do not exit. Every backend ends within 3 seconds. Tools that already ran stay applied, and the partial run can be reviewed or reverted like any other.
|
||||
|
||||
## Storage
|
||||
|
||||
A chat is one file in the project: `chats/<id>.json`, holding the title, the backend (kind, model, effort, CLI or ACP session id, agent) and the messages with their tool calls, usage and run ids. The API backend also keeps `chats/<id>.api.json`, the raw messages array (Messages format with thinking blocks, or Chat Completions format, whichever the endpoint speaks), so a follow-up replays exactly. The chat records its endpoint id. Deleting a chat asks first and removes both files, and its saved images.
|
||||
|
||||
Projects without a `chats/` folder load unchanged. The newest chat opens when you enter Chat mode.
|
||||
|
||||
## Privacy
|
||||
|
||||
PaperCraft never writes an API key into a project file, a chat or a log. The key lives in the OS credential store. The CLIs and ACP agents run with your own logins and do not get the API keys from the environment: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` and `CODEX_API_KEY` are removed from their environment, so Claude Code and Codex bill your account, not a key you set for PaperCraft. The MCP server listens on loopback only, as it does for any other client.
|
||||
|
||||
Prompts, project content and tool results go to whichever backend you chose: the CLI's or agent's model service, or the base URL of the endpoint you picked. Nothing goes to any other service. An npx agent downloads its package from npm the first time it runs. If a server echoes your key in an error message, the app hides it before showing the message.
|
||||
|
||||
## Limits
|
||||
|
||||
* One turn per project at a time. Sending while a turn runs is refused with "A turn is already running"; use Stop first. Switching to another project stops a running turn.
|
||||
* The web build has no Chat mode.
|
||||
* The canvas shows the run's outlines after the turn ends, when the run is reviewed. They are not drawn live while the agent works.
|
||||
* The API backend stops after 40 tool rounds in one message and says so.
|
||||
* Codex reports no per-call usage, so the context meter is hidden for it.
|
||||
* Agents need to be installed and signed in with their own tools. Chat does not install them or sign them in for you.
|
||||
* ACP terminal and file-system requests from an agent are answered as not supported. Agents use their own file tools.
|
||||
@@ -1,23 +1,53 @@
|
||||
# Design systems in PaperCraft
|
||||
|
||||
A PaperCraft project can hold a small design system: color variables, components with instances, and icon sets. Today these are built and managed through the tools (MCP or `papercraft-cli`); the editor shows the results but has no panel for them yet (see [ROADMAP.md](../ROADMAP.md)).
|
||||
A PaperCraft project can hold a design system: collections of typed variables with modes, components usable from any page, icon sets, and libraries that other projects can enable. The editor has a Variables modal, a Libraries modal and an Assets view for them (see [ROADMAP.md](../ROADMAP.md)); the same things are available through the tools (MCP or `papercraft-cli`).
|
||||
|
||||
## Variables
|
||||
|
||||
Named colors per project. Define them once, use them as `var(--name)`:
|
||||
Variables live in **collections**. A collection has one or more **modes** (`Light`, `Dark`) and each variable has one value per mode. The project stores them in `design.json` next to `project.json`; every page keeps a derived copy that the renderer, lint and exports read, rebuilt on every change. A change from the UI or a tool updates every page in one undo step per page.
|
||||
|
||||
```sh
|
||||
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7","on-accent":"#11111b"}}'
|
||||
papercraft-cli call set_variables '{"collection":"Spacing","type":"number","variables":{"space/md":16,"space/lg":{"value":24}}}'
|
||||
papercraft-cli call set_variables '{"variables":{"accent-hover":{"alias":"accent"}}}'
|
||||
```
|
||||
|
||||
* Use `var(--name)` in `write_html`, `update_styles` and `create_artboard` for `background`, `backgroundColor` and `color`. The fill stays linked and the link is saved in the file.
|
||||
* Calling `set_variables` again with a new value moves every linked fill, on every page, in one undo step.
|
||||
* **Modes.** `set_variables {"mode":"Dark", ...}` stores one set of values for a mode; define another mode the same way. The first mode you define is active. `set_variable_mode {"mode":"Light"}` switches the whole project.
|
||||
* `get_variables` lists the variables and modes. With `"format":"css"` it returns a `:root { --name: #hex }` block, with `"json"` a design-tokens object (`{"accent":{"$type":"color","$value":"#cba6f7"}}`).
|
||||
* **Limits.** Colors only. Fills and text color follow variables; strokes and gradients get the resolved color at write time and do not follow later changes. No spacing, radius or font-size variables yet.
|
||||
* **Types.** `color`, `number`, `string`, `boolean`. Numbers have no unit and bind as px. Strings are substituted as text (a font family, a shadow). Booleans bind to layer visibility. An alias points at a variable of the same type, in any collection or enabled library, and resolves per mode. A cycle is refused.
|
||||
* **Groups.** A name is split on `/`: `color/brand/primary` sits in the group `color/brand`. Renaming a group in the Variables modal renames every variable under it and the bindings follow.
|
||||
* **CSS names.** The name's segments joined with `-`, sanitized: `--color-brand-primary`. Use `var(--color-brand-primary)` in `write_html`, `update_styles` and `create_artboard` for `background`, `backgroundColor` and `color`. Two variables with the same CSS name across the collections of a project (library ones included) are refused when written.
|
||||
* **Modes.** The active mode is chosen per page and per collection (Design tab), not per project; a new page inherits the active page's choice. `set_variables {"mode":"Dark", ...}` stores values for a mode, and the first mode you define is active. `set_variable_mode {"mode":"Light"}` switches the whole project. `create_collection {"name":"Brand","modes":["Light","Dark"]}` makes a collection with its modes up front.
|
||||
* **Reading and exporting.** `get_variables` returns the collections (id, groups, type, value per mode, aliases, description, hidden), `pageModes`, and the older `variables` and `tokens` maps. With `"format":"css"` it returns a `:root { --name: #hex }` block plus `[data-theme]` rules for modes, with `"json"` a design-tokens object where groups nest on `/` and aliases are `{group.name}`, with `"tailwind"` an `@theme` block. Copy CSS, JSON and Tailwind are in the Variables modal too.
|
||||
* **Deleting.** `delete_variables {"names":[...]}` refuses a variable that another one aliases and names the dependents; `"force":true` detaches them to the resolved value first. In the UI the same asks for confirmation. A removal affects every page.
|
||||
* **Hidden.** A variable marked `hidden` stays in the file and is left out of published libraries.
|
||||
|
||||
Naming that works: roles, not hues (`surface`, `text-muted`, `accent`, `on-accent`, `danger`), so a mode can change the value without changing the meaning.
|
||||
|
||||
### Bindings
|
||||
|
||||
A fill that was written with `var(--name)` stays linked. Other fields bind with `bind_variable {"nodeIds":[...],"field":"gap","variable":"space/md"}` or with the variable button next to the field in the inspector. A bound field shows the variable as a chip with a detach button; detaching keeps the current value. `variable` may be the name or the CSS name; `null` detaches.
|
||||
|
||||
| Field | Type | Applies to |
|
||||
|---|---|---|
|
||||
| `stroke`, `text-color` | color | stroke color, text color |
|
||||
| `width`, `height` | number | size in px |
|
||||
| `gap` | number | auto-layout gap |
|
||||
| `padding`, `padding-top`, `padding-right`, `padding-bottom`, `padding-left` | number | auto-layout padding (`padding` sets all four) |
|
||||
| `radius` | number | corner radius |
|
||||
| `font-size` | number | text size |
|
||||
| `line-height` | number | below 4 it is a multiplier (`1.5`), from 4 up it is px |
|
||||
| `opacity` | number | 0 to 1, values outside are clamped |
|
||||
| `visible` | boolean | layer visibility |
|
||||
|
||||
A bound field is applied again when the variable, an alias target or the page's mode changes. Instances take bindings from their main; changing a bound field on an instance is an override for that instance only. Gradients cannot be bound yet.
|
||||
|
||||
### Migration
|
||||
|
||||
Projects made before collections open as project version 2: the first page's colors and modes become the collection `Colors`, its tokens become `Tokens` (a `16px` value becomes a number, anything else, including a unitless `1.5`, stays a string). CSS names do not change, so existing fill bindings keep working. PaperCraft 0.4.0 does not know `design.json`, so it refuses a migrated project with "made by a newer PaperCraft" instead of writing pages that would drift from it. Update before opening such a project on another machine.
|
||||
|
||||
### Variables modal
|
||||
|
||||
Open it from the Design tab ("Open variables"), the command palette (`Variables…`) or ⌘⌥⇧V. Collections of this file are on the left, then each enabled library (read-only), each with its group tree. The table has one row per variable in the selected group and one column per mode; cells edit in place (swatch and hex, number, text, toggle) and an alias picker links to another variable. Add a variable (choose its type), a mode or a collection from the header; rename, delete, search and a details pane (description, hidden, CSS name, how many layers use it) are there too.
|
||||
|
||||
## Components and instances
|
||||
|
||||
```sh
|
||||
@@ -29,8 +59,9 @@ papercraft-cli call set_text_content '{"updates":[{"nodeId":"<label layer id fro
|
||||
* Build the main component as a frame (usually its own artboard), then convert it. Name it `Component / Variant`.
|
||||
* `create_instance` returns the instance id and its layers, so you can set text and styles on them. Changes made through `set_text_content` or `update_styles` on an instance are recorded as overrides; everything else follows the main component.
|
||||
* In `write_html`, `<x-paper-instance component-id="<id>" layer-name="Button / Save"/>` places an instance inline.
|
||||
* `get_components` lists main components and how many instances each has.
|
||||
* **Limits.** Components and their instances must be on the same page. No variants or properties: model variants as separate components (`Button / Primary`, `Button / Secondary`). In the editor, detach with ⌘⌥B; there is no detach or swap tool.
|
||||
* `get_components` lists the main components of every page (and library components) with `componentKey`, page and how many instances each has.
|
||||
* **Across pages.** Instances can be placed from a main on any page: `create_instance` and `swap_instance` take a `componentId` (`pageId:num`) or a `componentKey` (stable, from `get_components`). Editing a main on page A updates its instances on every other page, one undo step per page. If the main is deleted its instances stay as they are, marked "Main missing" in Assets, where you can detach them. `import_components` still copies a main onto a page, but reuse no longer needs it.
|
||||
* **Limits.** No variants or properties: model variants as separate components (`Button / Primary`, `Button / Secondary`). `detach_instance` and `swap_instance` exist as tools; in the editor detach with ⌘⌥B.
|
||||
|
||||
## Icons
|
||||
|
||||
@@ -44,9 +75,8 @@ Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based an
|
||||
|
||||
> **Known limitation.** An icon's *stroke* color is frozen at import time: the value of the
|
||||
> variable is baked into the stroke, so a stroke-based icon (Lucide, Tabler, Heroicons) does
|
||||
> not follow the variable when it changes later — only fills keep the link. Letting strokes
|
||||
> follow a variable needs a `stroke_var` field on the style, which changes the saved page
|
||||
> format, so it is on the backlog. A `var(--x)` that does not exist is reported as a warning
|
||||
> not follow the variable when it changes later. A layer's `stroke` can follow a variable with
|
||||
> `bind_variable`; the icon's own stroke does not. A `var(--x)` that does not exist is reported as a warning
|
||||
> and the icon keeps the color it inherits.
|
||||
|
||||
## Lint
|
||||
@@ -65,6 +95,22 @@ Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based an
|
||||
|
||||
Instances are skipped (their main component is linted). The rules are conventions, not law: pass `scale` to match your own type scale and ignore `info` items you disagree with. Lint does not judge taste; always look at `get_screenshot` too.
|
||||
|
||||
## Libraries
|
||||
|
||||
Any project can publish itself as a library, and other projects can enable it. This is a published snapshot, as in Figma, not a live link.
|
||||
|
||||
* **Publish.** Libraries modal, tab "This file", or `publish_library`. It writes `library.json` in the project folder with a version counter, the non-hidden variables, and every main component whose name does not start with `_` or `.`, each with its layers and a 256px preview. Before publishing the modal shows what changed since the last publish ("3 variables changed, 1 component added"); the tool returns the same list.
|
||||
* **Enable.** Every project in the store with a `library.json` is available (projects synced from other devices through the relay show up too). `enable_library {"libraryId":...}` or the toggle in the Libraries modal copies the snapshot into the project (`libraries/<id>.json`). Its variables show read-only in the Variables modal and can be bound and aliased; its components show in Assets and are placed with `create_instance {"componentKey":...}`. Enabling is refused if a CSS name already exists in the project.
|
||||
* **Updates.** When the published version is newer than the copy, the Libraries modal tab "Updates" and a dot on the Assets tab say so, and `list_libraries` reports `updateAvailable`. The review lists changed variables and components. `update_library` (or Update in the modal) replaces the snapshot, rebuilds every page and re-syncs the library's instances; instance overrides are kept. Updates are never applied by themselves.
|
||||
* **Missing source.** If the library's project is deleted or not in the store, the copy keeps working. The modal shows "Source not found" and `list_libraries` shows `sourceFound: false`.
|
||||
* **Disable.** `disable_library` or the toggle (after a confirm when it is in use) stops using a library: bindings to its variables are detached and keep the resolved value, local variables that aliased it keep the resolved value, its instances become plain frames, and its variables and components leave the project. One undo step per page.
|
||||
|
||||
Open the Libraries modal from the Share menu (`Libraries…`), the command palette, or the Assets header.
|
||||
|
||||
## Assets
|
||||
|
||||
The Assets view (side panel tab) replaces the old Library tab. It has a search box and sections for this file (by page), each enabled library, and My shapes (the personal shape library). Components are grouped by the part of the name before the first ` / ` (`Button / Primary` is in group `Button`) and shown as previews. Click one to place it at the viewport center, or drag it onto the canvas. Instances whose main was deleted are listed as "Main missing" with a detach action.
|
||||
|
||||
## Packages
|
||||
|
||||
A design system can travel as an **OpenDesign design-system package**: a folder with
|
||||
|
||||
@@ -11,6 +11,8 @@ Ranked by value for the effort. Done items say where.
|
||||
* Merge of concurrent edits to one shared page (`src/merge.rs`, `src/sync.rs`).
|
||||
* Interface scale in Settings and the command palette (native).
|
||||
* Export presets (inspector Export "+").
|
||||
* Shared libraries: publish a project as a library, enable and update it in others (`src/libraries.rs`, Libraries modal); cross-page components; number variables.
|
||||
* Design from the CLI with the app closed (`papercraft-cli --offline`, `shell`).
|
||||
* Auto versions before agent edits: each agent run snapshots edited pages and can be reviewed or reverted (`src/runs.rs`, Agent view).
|
||||
|
||||
## Next
|
||||
@@ -22,4 +24,4 @@ Ranked by value for the effort. Done items say where.
|
||||
7. **Comment threads with replies from people, mentions, and a canvas pin that opens the thread**.
|
||||
8. **Flatten menus into icon toolbars**: the Share menu and the folded Menu are flat lists; next is an icon rail for the most used actions with labels on hover.
|
||||
9. **Window**: Windows asks the system for rounded corners and keeps the shadow. On Linux the window manager (or the Wayland client-side frame) draws the frame, and it is round on GNOME and KDE. A frame drawn by the app needs three things that cannot be checked without a Linux desktop: a transparent surface, a paint callback that clears the four corners with replace blending (egui cannot erase pixels it has already drawn), and its own edge resizing through `drag_resize_window`. Not built for that reason.
|
||||
10. **One design-system package per brand** imported by every app's design file, plus a token export that generates the theme code (see `docs/shared-libs.md`).
|
||||
10. **One design-system package per brand** imported by every app's design file, plus a token export that generates the theme code (see `docs/shared-libs.md`). Publishing the brand project as a library covers the sharing; the generated theme code is still todo.
|
||||
|
||||
|
Before Width: | Height: | Size: 718 KiB After Width: | Height: | Size: 718 KiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 593 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: 625 KiB After Width: | Height: | Size: 636 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 815 KiB After Width: | Height: | Size: 838 KiB |
|
Before Width: | Height: | Size: 696 KiB After Width: | Height: | Size: 691 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 709 KiB |
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 882 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -36,7 +36,7 @@ Allowed spacing values: **4, 8, 12, 16, 24, 32, 48, 64** (and 96 for page sectio
|
||||
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`.
|
||||
* Semantic colors carry meaning only: red means error or destructive, green means success. Do not use them decoratively.
|
||||
* If a color you need is not a variable, add the variable, then use it. `lint_design` reports hard-coded colors that equal a variable.
|
||||
* Radii, spacing and type sizes cannot be variables yet, so keep them on the lists in this skill and define them once on the design-system page.
|
||||
* Spacing, radius and type sizes are number variables (`space/md`, `radius/sm`, `font/body`): define them once, in a collection of their own, and bind fields with `bind_variable`. Keep the values on the lists in this skill. Group names with `/` and alias semantic names to base ones (`color/button/bg` to `color/brand/primary`).
|
||||
|
||||
## 3. Type scale
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
|
||||
* Not running: ask the user to start PaperCraft and open a project (or `create_file` then `open_file`). `PAPERCRAFT_MCP_PORT` must match if the port was changed. Do not try to launch the GUI yourself unless asked.
|
||||
* Read `get_basic_info` (pages, artboards, sizes, fonts), then `get_selection` (what the user is looking at) and `get_variables` and `get_components`. Reuse what exists before creating anything.
|
||||
* `pageId` defaults to the page the user is viewing, which can change between calls: pass it explicitly. Node ids look like `3fa9c1:14`; never show them to the user.
|
||||
* App closed (server, CI)? `papercraft-cli call --offline ... --project <name>` runs the same tools on the project store and saves after every editing call; `papercraft-cli shell` keeps one session for many calls and reads scripts from stdin. It is refused while the app has that project open. See `docs/agents.md`.
|
||||
* With MCP tools you do not need the CLI; with a shell, `papercraft-cli list` and `papercraft-cli schema <tool>` give the schemas on demand. Pass big JSON on stdin: `... | papercraft-cli call write_html -`.
|
||||
|
||||
## 2. The loop: build, look, lint, fix
|
||||
@@ -85,8 +86,9 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
|
||||
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css or json), `set_variables` (`mode`), `set_variable_mode` |
|
||||
| Reuse | `create_component`, `create_instance`, `get_components`, `<x-paper-instance>` |
|
||||
| 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>` |
|
||||
| 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` |
|
||||
| Check quality | `lint_design` |
|
||||
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
|
||||
@@ -97,11 +99,11 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
## 6. Known limits (design around them)
|
||||
|
||||
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
|
||||
* **Variables are colors only**, for fills and text color. Strokes and gradients keep fixed colors. Spacing, radius and type sizes are plain numbers: keep them on the scale by discipline and `lint_design`.
|
||||
* **Variables** are typed (`color`, `number`, `string`, `boolean`), live in collections with modes, group with `/` (`space/md`, `color/brand/primary`) and can alias each other. Colors work as `var(--name)` on fills and text; every other field binds with `bind_variable` (stroke, text-color, width, height, gap, padding, radius, font-size, line-height, opacity, visible). Numbers bind as px; line-height below 4 is a multiplier. Gradients keep fixed colors.
|
||||
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
|
||||
* **No rotation.** Nodes are axis-aligned.
|
||||
* **Components and their instances live on one page.** Put shared components on the page where they are used, or duplicate the component per page.
|
||||
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). No swap or detach tool.
|
||||
* **Components work across pages**: `create_instance` takes a main from any page or enabled library (`componentId` or `componentKey` from `get_components`) and follows later edits. To share with other projects, `publish_library` there and `enable_library` here; `update_library` pulls a newer publish.
|
||||
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). `swap_instance` and `detach_instance` exist.
|
||||
* **Remote images are not fetched.** Use a local path or `data:` URI.
|
||||
* **One project open at a time**; moving layers between pages is not supported.
|
||||
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
|
||||
|
||||
@@ -5,12 +5,10 @@ Generated from `papercraft-cli schema <tool>`; run that command for the authorit
|
||||
|
||||
## Projects and pages
|
||||
|
||||
|
||||
### `list_resources` (read-only)
|
||||
|
||||
Lists the user's papercraft projects (local folders), most recently modified first. Each item has id, name, path, page count, updatedAt, and `open` for the project open in the app.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `limit` | integer | | Maximum projects to return. Defaults to 50. |
|
||||
@@ -25,7 +23,6 @@ Opens a project in the app (saving the current one) and returns a get_basic_info
|
||||
|
||||
Creates a new empty project and returns its id. Call open_file with the id to work in it.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | | Optional project name. |
|
||||
@@ -35,7 +32,6 @@ Creates a new empty project and returns its id. Call open_file with the id to wo
|
||||
|
||||
Creates a new page at the end of the page list and returns its id, without switching the user's page. Use the returned pageId in later calls.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | | Optional page name. Defaults to "Page N". |
|
||||
@@ -45,7 +41,6 @@ Creates a new page at the end of the page list and returns its id, without switc
|
||||
|
||||
Rename one or more pages. Does not switch the user's page.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | Page renames. |
|
||||
@@ -55,16 +50,14 @@ Rename one or more pages. Does not switch the user's page.
|
||||
|
||||
Delete one or more pages. The last page of a project cannot be deleted. A page that still has layers is only deleted with `confirm: true`; check what is on it first (get_basic_info lists node counts).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `pageIds` | array of string | yes | Pages to delete. |
|
||||
| `confirm` | boolean | | Required to delete a page that has layers. |
|
||||
| `pageIds` | array of string | yes | Pages to delete. |
|
||||
|
||||
|
||||
## Read
|
||||
|
||||
|
||||
### `get_basic_info` (read-only)
|
||||
|
||||
Essential context about the open project: project name, the page, node count, artboards (top-level frames) with ids, sizes and world positions, other top-level layers, fonts, and `pages` with the active one marked isActive.
|
||||
@@ -81,7 +74,6 @@ The selected layers on the page the user is viewing: ids, names, types, sizes, p
|
||||
|
||||
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The node to inspect. |
|
||||
@@ -91,7 +83,6 @@ Details of one node: type, name, size, visibility, lock state, parent, children
|
||||
|
||||
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The parent node, or "root". |
|
||||
@@ -101,7 +92,6 @@ Direct children of a node (or of the page root when nodeId is "root"), with ids,
|
||||
|
||||
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `depth` | number | | Maximum depth (default 3, max 10). |
|
||||
@@ -110,54 +100,50 @@ Compact indented text tree of a subtree: type, name, id and size per node. Much
|
||||
|
||||
### `get_jsx` (read-only)
|
||||
|
||||
Export a node and its descendants to code: `"inline"` (default, React JSX with inline styles), `"tailwind"` (React JSX with Tailwind CSS utility classes), `"react"` (typed React TSX component with Tailwind classes), `"angular"` (Angular standalone `@Component` with template and CSS), `"html"` (self-contained `<style>` + HTML markup), or `"egui"` (Rust `egui` layout code with a `tokens` module).
|
||||
|
||||
Export a node and its descendants to code: "inline" (default, React JSX with inline styles), "tailwind" (React JSX with Tailwind CSS utility classes), "react" (React TSX component), "angular" (Angular v17+ standalone component), "html" (self-contained <style> + HTML markup), or "egui" (Rust egui 0.36 code with tokens).
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: `"inline"` (default), `"tailwind"`, `"react"`, `"angular"`, `"html"`, or `"egui"`. |
|
||||
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: "inline" (default), "tailwind", "react", "angular", "html", or "egui". |
|
||||
| `nodeId` | string | yes | The node to convert. |
|
||||
|
||||
|
||||
### `get_design_spec` (read-only)
|
||||
|
||||
Extract a comprehensive AI design-to-code specification (`ai_spec_markdown` and structured JSON `spec`) for a node and its descendants. Includes the hierarchy, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), bound design tokens (`--var`), component instances (`instanceOf` on each node plus `componentDefinitions`), icons (`kind:"icon"` with set, name, size and tint), vector SVG (`svg`, `svgBounds`), text metrics (weight, line height, letter spacing), min and max sizes, fills and gradients, token matches for spacing, radius and type, implementation rules, and reference code for the chosen `target`. Always call this before implementing a design in code.
|
||||
|
||||
Export a deterministic AI Design-to-Code specification for a frame or subtree. Includes all design tokens/variables, component instances, exact auto-layout tree (sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory enforcement rules, and reference target code. Always call this before implementing a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing from a screenshot.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The root frame or node to extract the spec for. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target framework/format (defaults to `"react"`). |
|
||||
| `format` | `markdown` \| `json` | | Output format: "markdown" (default) or "json". |
|
||||
| `nodeId` | string | yes | Root frame or node id to export. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target UI framework (default "react"). |
|
||||
|
||||
|
||||
### `verify_code` (read-only)
|
||||
|
||||
Enforce design-to-code fidelity by checking written `code` against the source PaperCraft node (`nodeId`) for the given `target`. Reports a `score` (0–100), `passed` boolean, and structured violations (`missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, `spacing-drift`) with actionable fixes.
|
||||
|
||||
Verify that AI-generated UI code (`react`, `tailwind`, `angular`, `html`, or `egui`) accurately implements a design subtree. Checks for missing text strings, hardcoded hex colors where `--var` or `tokens::*` must be used, forbidden `position: absolute` on auto-layout flow children, missing flex/grid/egui layout containers, unreferenced component instances, and spacing drift. Returns `{passed, score, errors, warnings, violations}`.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `code` | string | yes | The generated or hand-written implementation code to verify. |
|
||||
| `nodeId` | string | yes | The PaperCraft node id the code implements. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target language/framework of `code`. |
|
||||
| `code` | string | yes | The generated UI source code to verify against the design. |
|
||||
| `nodeId` | string | yes | Root frame or node id that the code implements. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target UI framework. |
|
||||
|
||||
|
||||
### `diff_frames` (read-only)
|
||||
|
||||
Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level differences (size, position, fills, stroke, corner radius, opacity, text, layout, added/removed children).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `beforeId` | string | yes | First frame or node id (baseline). |
|
||||
| `afterId` | string | yes | Second frame or node id (comparison). |
|
||||
| `beforeId` | string | yes | First frame or node id (baseline). |
|
||||
|
||||
|
||||
### `get_computed_styles` (read-only)
|
||||
|
||||
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to read. |
|
||||
@@ -167,7 +153,6 @@ CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
|
||||
|
||||
Find nodes by text content and/or computed style. Searches the whole project by default; pass pageId for one page or nodeId for a subtree. `textValue` matches text case-insensitively with * wildcards anchored to the whole value. `filters` are {styleName, styleValue} pairs (AND), values may use * wildcards; colors match by value.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `filters` | array of object | | |
|
||||
@@ -179,7 +164,6 @@ Find nodes by text content and/or computed style. Searches the whole project by
|
||||
|
||||
The image used by a node's image fill, as image content.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | A node with an image fill. |
|
||||
@@ -189,7 +173,6 @@ The image used by a node's image fill, as image content.
|
||||
|
||||
Whether font families are available and their weights/styles. papercraft renders text with Inter (400, 700, normal and italic); other families fall back to Inter.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `familyNames` | array of string | yes | |
|
||||
@@ -199,7 +182,6 @@ Whether font families are available and their weights/styles. papercraft renders
|
||||
|
||||
Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what write_html supports), "whiteboard" (shapes, arrows, stickies, hand-drawn style).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `topic` | string | yes | |
|
||||
@@ -207,16 +189,14 @@ Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what w
|
||||
|
||||
## See and export
|
||||
|
||||
|
||||
### `get_screenshot` (read-only)
|
||||
|
||||
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits. In `papercraft-cli`, pass `-o`/`--out FILE` or `{"out": "FILE"}` to choose the output file path.
|
||||
|
||||
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | | The node to capture; omit for the whole page. |
|
||||
| `out` | string | | Optional file path to save the PNG to (also used by `papercraft-cli`). |
|
||||
| `out` | string | | Optional file path to save the PNG to (also used by papercraft-cli). |
|
||||
| `scale` | number | | Render scale: 1 (default) or 2. |
|
||||
|
||||
|
||||
@@ -224,7 +204,6 @@ Render a node (or the whole page when nodeId is omitted or "root") to a PNG and
|
||||
|
||||
Export nodes to files. `nodes` maps node ids to export settings ([{format: png|svg|pdf, scale: "2x" | "512w" | "512h" | "720p"}]; [] = 2x PNG). Files go to `directory` (default: the Downloads folder). Returns the paths written.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `directory` | string | | Output folder (absolute path). |
|
||||
@@ -235,7 +214,6 @@ Export nodes to files. `nodes` maps node ids to export settings ([{format: png|s
|
||||
|
||||
Export several nodes into one PDF, one page per node, ordered top-to-bottom then left-to-right. Returns the file path.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to put on the pages. |
|
||||
@@ -244,12 +222,10 @@ Export several nodes into one PDF, one page per node, ordered top-to-bottom then
|
||||
|
||||
## Write
|
||||
|
||||
|
||||
### `create_artboard`
|
||||
|
||||
Creates an artboard (top-level frame) and returns its id; then add content with write_html({mode: "insert-children"}). `styles` are camelCase CSS; width and height are required (e.g. "1440px"). Artboards default to display:flex, flexDirection:column and a white background, and are placed in an empty spot unless left/top are given. Default sizes: desktop 1440×900, tablet 768×1024, mobile 390×844.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | yes | Layer name. |
|
||||
@@ -261,18 +237,17 @@ Creates an artboard (top-level frame) and returns its id; then add content with
|
||||
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
|
||||
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
|
||||
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including `wavy` and `text-decoration-color`); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
|
||||
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
|
||||
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
|
||||
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node (supports cross-page `<page>:<id>`).
|
||||
Returns the ids of the created top-level layers and the styles that were ignored.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `html` | string | yes | HTML to parse into layers. |
|
||||
| `mode` | insert-children/replace | yes | |
|
||||
| `mode` | `insert-children` \| `replace` | yes | |
|
||||
| `targetNodeId` | string | yes | Target node id, or "root" for the page. |
|
||||
|
||||
|
||||
@@ -280,7 +255,6 @@ Returns the ids of the created top-level layers and the styles that were ignored
|
||||
|
||||
Update styles on one or more nodes (camelCase CSS, like React.CSSProperties). Setting left/top on an artboard moves it. Hand-drawn keys: sketch, roughness, fillStyle, strokeStyle, seed. Styles with no effect are returned under ignoredStyles.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | Each item applies styles to nodeIds. |
|
||||
@@ -290,7 +264,6 @@ Update styles on one or more nodes (camelCase CSS, like React.CSSProperties). Se
|
||||
|
||||
Set the text of one or more Text or Sticky nodes (batch).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | |
|
||||
@@ -300,7 +273,6 @@ Set the text of one or more Text or Sticky nodes (batch).
|
||||
|
||||
Rename layers (batch). Names are truncated to 50 characters.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | |
|
||||
@@ -310,7 +282,6 @@ Rename layers (batch). Names are truncated to 50 characters.
|
||||
|
||||
Delete nodes and their descendants. Verify parents with get_node_info first when unsure.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to delete. |
|
||||
@@ -320,27 +291,24 @@ Delete nodes and their descendants. Verify parents with get_node_info first when
|
||||
|
||||
Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; `<x-paper-clone node-id="<page>:<id>"/>` also works across pages). Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodes` | array of object | yes | Items accept `{id, parentId?, toPageId?}`. |
|
||||
| `nodes` | array of object | yes | |
|
||||
|
||||
|
||||
### `move_nodes`
|
||||
|
||||
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `moves` | array of object | yes | Items accept `{nodeId, before?, after?, parentId?, toPageId?, index?}`. |
|
||||
| `moves` | array of object | yes | |
|
||||
|
||||
|
||||
### `create_shape`
|
||||
|
||||
Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, star, line, arrow, sticky (sticky note with text) or text. Coordinates are world units, or relative to parentId when given. Lines and arrows take `points` ([[x,y],...], at least two) and arrows may bind to layers with startNodeId/endNodeId (the arrow then follows them). `styles` takes the same CSS as update_styles plus the hand-drawn keys sketch, roughness (0 architect, 1 artist, 2 cartoonist), fillStyle (hachure, cross-hatch, solid, zigzag, dots), strokeStyle (solid, dashed, dotted). Arrows also take route (straight, curved, elbow) and startHead/endHead (none, arrow, triangle, dot, bar, diamond).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `endHead` | string | | |
|
||||
@@ -349,13 +317,13 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
|
||||
| `name` | string | | |
|
||||
| `parentId` | string | | Frame to place the shape in. |
|
||||
| `points` | array of array | | |
|
||||
| `route` | straight/curved/elbow | | |
|
||||
| `route` | `straight` \| `curved` \| `elbow` | | |
|
||||
| `sides` | integer | | Polygon sides / star points. |
|
||||
| `startHead` | string | | |
|
||||
| `startNodeId` | string | | |
|
||||
| `styles` | object | | CSS and hand-drawn styles. |
|
||||
| `text` | string | | Text for sticky and text. |
|
||||
| `type` | rectangle/ellipse/diamond/triangle/polygon/star/line/arrow/sticky/text | yes | |
|
||||
| `type` | `rectangle` \| `ellipse` \| `diamond` \| `triangle` \| `polygon` \| `star` \| `line` \| `arrow` \| `sticky` \| `text` | yes | |
|
||||
| `width` | number | | |
|
||||
| `x` | number | | |
|
||||
| `y` | number | | |
|
||||
@@ -363,33 +331,71 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
|
||||
|
||||
## Design system
|
||||
|
||||
|
||||
### `get_variables` (read-only)
|
||||
|
||||
Color variables and design tokens of the page's project (name -> hex/value). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` `css` returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), `json` returns a W3C DTCG design-tokens object, and `tailwind` returns a Tailwind v4 `@theme` block.
|
||||
|
||||
Variables of the page's project. Default JSON: `variables` (name -> hex), `tokens`, and `collections` (id, name, modes, defaultMode, variables with id, name, cssName, group, type color|number|string|boolean, values per mode name, aliases as {"alias": name}, description, hidden) plus `pageModes` (active mode per collection on this page). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), json a W3C DTCG object (groups nest on `/`, aliases are {group.name}), and tailwind a Tailwind v4 `@theme` block.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | `css` \| `json` \| `tailwind` | | Output format: `css`, `json` (W3C DTCG), or `tailwind` (`@theme`). |
|
||||
| `format` | `css` \| `json` \| `tailwind` | | |
|
||||
|
||||
|
||||
### `set_variables`
|
||||
|
||||
Define or change color variables (names like `surface`, `accent-text`), on every page. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
|
||||
|
||||
Define or change variables (names like `surface`, `accent-text`, or grouped `color/brand/primary`), on every page. Plain {"name": "#hex"} defines a color. An entry may be an object {value, type, collection, mode, alias, description, hidden}: `type` color|number|string|boolean (numbers bind as px), `collection` a name or id (created if missing), `alias` the name of another variable of the same type. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `collection` | string | | Default collection (name or id) for entries that name none. |
|
||||
| `mode` | string | | Store the values for this mode (e.g. "Light", "Dark"); they apply when it is the active mode. The first mode you define becomes active. |
|
||||
| `variables` | object | yes | {"accent": "#cba6f7", "surface": "#313244"} |
|
||||
| `type` | `color` \| `number` \| `string` \| `boolean` | | Default type for entries that name none. |
|
||||
| `variables` | object | yes | {"accent": "#cba6f7"} or {"space/md": {"value": 16, "type": "number"}, "accent-hover": {"alias": "accent"}} |
|
||||
|
||||
|
||||
### `create_collection`
|
||||
|
||||
Create a variable collection (a set of variables with modes), e.g. `Brand` with modes ["Light", "Dark"]. Without `modes` it has one mode, `Default`. Then fill it with set_variables {collection}.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `modes` | array of string | | |
|
||||
| `name` | string | yes | |
|
||||
|
||||
|
||||
### `delete_variables`
|
||||
|
||||
Delete variables by name. A variable another one aliases is refused (the error names the dependents) unless `force` is true, which gives the dependents its resolved value.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `force` | boolean | | |
|
||||
| `names` | array of string | yes | |
|
||||
|
||||
|
||||
### `set_tokens`
|
||||
|
||||
Define non-color design tokens (spacing, radius, font size, shadows, widths), on every page: {"spacing-md": "16px", "radius-md": "8px", "shadow-md": "0 4px 12px rgba(0,0,0,0.08)"}. Use `var(--spacing-md)` in any style value (padding: var(--spacing-sm) var(--spacing-md), boxShadow: var(--shadow-md)); it is replaced by the value when written, so tokens are a shared vocabulary, not live links. Listed by get_variables.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `tokens` | object | yes | |
|
||||
|
||||
|
||||
### `bind_variable`
|
||||
|
||||
Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, line-height, opacity, visible. `variable` is a name like color/brand/primary or its css name; null or omitted detaches (the current value stays). The value applies at once; on an instance the field becomes an override.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `field` | string | yes | |
|
||||
| `nodeIds` | array of string | yes | Layers to bind. |
|
||||
| `variable` | string \| null | | |
|
||||
|
||||
|
||||
### `set_variable_mode`
|
||||
|
||||
Switch the active variable mode (defined with set_variables {mode}): every variable takes that mode's value and all fills following them change, on every page. One call turns a whole design from light to dark.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `mode` | string | yes | |
|
||||
@@ -399,44 +405,38 @@ Switch the active variable mode (defined with set_variables {mode}): every varia
|
||||
|
||||
Import an OpenDesign design-system package: a folder with manifest.json, tokens.css and (optional) DESIGN.md. The colors of the `:root` block become variables, the colors of the `[data-theme="name"]` blocks become modes and every other value becomes a token. Replaces the variables, modes and tokens of every page in one undo step. Only hex colors become variables; oklch(), rgb() and sizes stay tokens.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `path` | string | yes | Folder of the package to import. |
|
||||
| `pageId` | string | | The design system is project-wide, so it lands on every page. |
|
||||
|
||||
|
||||
### `export_design_system`
|
||||
|
||||
Write the page's variables, modes and tokens as an OpenDesign design-system package (manifest.json, tokens.css and DESIGN.md) in `path`, so it can be read back with import_design_system or by other tools. Returns the files written.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `path` | string | yes | Folder to write the package into. |
|
||||
| `pageId` | string | | The page whose design system is written. |
|
||||
|
||||
|
||||
### `get_components` (read-only)
|
||||
|
||||
Main components on a page (id, name, size) with how many instances each has.
|
||||
Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), `pageId`, `pageName`, size, and `uses` (instances across all pages).
|
||||
|
||||
|
||||
### `import_components`
|
||||
|
||||
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`. Components with the same name already here are skipped. Copies are placed in rows (wrapping at 3200 px) to the right of the page content; shelf-pack them yourself using their real sizes if you want a tidy parking area. Variables and tokens are already shared by every page; components are copies, not live links.
|
||||
|
||||
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with create_instance or <x-paper-instance component="Name"/> / component-id="ID". Components with the same name already here are skipped. Variables and tokens are already shared by every page; components are copies, not live links.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `fromPageId` | string | yes | Source page id. |
|
||||
| `fromPageId` | string | yes | |
|
||||
| `names` | array of string | | Only these components (default all). |
|
||||
|
||||
|
||||
### `create_component`
|
||||
|
||||
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or `<x-paper-instance component="Name" component-id="ID"/>`. Components and instances live on one page.
|
||||
|
||||
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or <x-paper-instance>. Instances may live on any page and follow the main.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -448,10 +448,10 @@ Turn a frame or artboard into a main component. Build reusable parts (button, ro
|
||||
|
||||
Place an instance of a component. Edit text and styles on the instance's descendants with set_text_content / update_styles (they become overrides); edits to the main component flow to every instance. Returns the instance id and its text layers.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `componentId` | string | yes | |
|
||||
| `componentId` | string | | A main on any page ("pageId:num"). |
|
||||
| `componentKey` | string | | Instead of componentId: the key from get_components. |
|
||||
| `index` | number | | Position among the parent's children. |
|
||||
| `name` | string | | |
|
||||
| `parentId` | string | | Parent frame; omit or "root" for the page. |
|
||||
@@ -461,12 +461,12 @@ Place an instance of a component. Edit text and styles on the instance's descend
|
||||
|
||||
### `swap_instance`
|
||||
|
||||
Swap an instance root to point at a different main component on the same page, preserving overrides on descendants matched by name and layer type.
|
||||
|
||||
Swap an instance root to point at a different main component (any page), preserving overrides on descendants matched by name and layer type.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `componentId` | string | yes | The new main component id. |
|
||||
| `componentId` | string | | The new main component id. |
|
||||
| `componentKey` | string | | Instead of componentId: a key from get_components, including a library component. |
|
||||
| `nodeId` | string | yes | The instance root node to swap. |
|
||||
|
||||
|
||||
@@ -474,7 +474,6 @@ Swap an instance root to point at a different main component on the same page, p
|
||||
|
||||
Detach one or more component instances into plain frames, breaking their link to the main component while keeping their current appearance and children.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
|
||||
@@ -484,7 +483,6 @@ Detach one or more component instances into plain frames, breaking their link to
|
||||
|
||||
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `limit` | number | | Default 20. |
|
||||
@@ -492,11 +490,10 @@ Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material).
|
||||
| `set` | string | | |
|
||||
|
||||
|
||||
### `lint_design` (read-only)
|
||||
### `lint_design`
|
||||
|
||||
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `fix` | boolean | | Apply safe automatic fixes before reporting remaining issues. |
|
||||
@@ -504,37 +501,75 @@ Check a page or subtree for common design mistakes and get a fix for each: text
|
||||
| `scale` | array of number | | Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64. |
|
||||
|
||||
|
||||
## Session
|
||||
## Libraries
|
||||
|
||||
### `publish_library`
|
||||
|
||||
### `get_feedback` (read-only)
|
||||
|
||||
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then `resolve_feedback`. `get_selection` also includes the selected layers' notes.
|
||||
|
||||
Publish this project's variables (not hidden) and components (not named with a leading `_` or `.`) as a library other projects can enable. Writes library.json with the next version and returns what changed since the last publish.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `includeResolved` | boolean | | Include resolved notes. |
|
||||
| `description` | string | | What the library is for (kept from the last publish when omitted). |
|
||||
|
||||
|
||||
### `list_libraries` (read-only)
|
||||
|
||||
Libraries other projects published (`available`, with `enabled`) and the ones this project uses (`enabled`: version, sourceVersion, sourceFound, updateAvailable).
|
||||
|
||||
|
||||
### `enable_library`
|
||||
|
||||
Use a published library: its variables become read-only, bindable and aliasable, and its components can be placed with create_instance componentKey. Refused when a CSS name is already defined here.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `libraryId` | string | yes | From list_libraries. |
|
||||
|
||||
|
||||
### `update_library`
|
||||
|
||||
Apply the library's newest publish to this project: variables and components are replaced and every instance of it re-synced, overrides kept. Returns the changes.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `libraryId` | string | yes | |
|
||||
|
||||
|
||||
### `disable_library`
|
||||
|
||||
Stop using a library: bindings to its variables are detached (values kept), its instances become plain frames, its variables and components leave this project.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `libraryId` | string | yes | |
|
||||
|
||||
|
||||
## Session
|
||||
|
||||
### `get_feedback` (read-only)
|
||||
|
||||
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then resolve_feedback. `get_selection` also includes the selected layers' notes.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `includeResolved` | boolean | | |
|
||||
| `nodeId` | string | | Only this layer. |
|
||||
| `pageId` | string | | Omit to query all pages across the project. |
|
||||
|
||||
|
||||
### `add_feedback`
|
||||
|
||||
Leave a note on a layer (author `agent` by default), e.g. a question or a problem you could not fix. The user sees it in the inspector and as a dot on the canvas.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `author` | string | | Defaults to `agent`. |
|
||||
| `author` | string | | |
|
||||
| `nodeId` | string | yes | |
|
||||
| `text` | string | yes | |
|
||||
|
||||
|
||||
### `resolve_feedback`
|
||||
|
||||
Mark notes on a layer resolved, with a short reply saying what you changed. Without `commentId` every open note on the layer is resolved. `delete` removes instead.
|
||||
|
||||
Mark notes on a layer resolved, with a short reply saying what you changed. Without commentId every open note on the layer is resolved. `delete` removes instead.
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -548,7 +583,6 @@ Mark notes on a layer resolved, with a short reply saying what you changed. With
|
||||
|
||||
Select nodes on the page the user is viewing so they can see what you mean; zoom: true also frames them in the viewport. An empty list clears the selection.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to select. |
|
||||
@@ -569,8 +603,6 @@ Redo the last undone change on a page.
|
||||
|
||||
Call when done creating or editing. Saves the project.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | | Optional nodes you worked on. |
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,71 @@
|
||||
# Annotations: notes with leader lines that explain a design
|
||||
|
||||
## Why (user request, 2026-10-21)
|
||||
|
||||
Teams annotate designs in Figma with callouts and arrows that explain what each part does: business rules, behaviour, states, data sources, edge cases. They are toggled on for review and handoff and off for presenting. PaperCraft has comments (threads, open/resolved) but nothing that documents a design in place.
|
||||
|
||||
## Model
|
||||
|
||||
* An **annotation** belongs to a page: `{ id, target: NodeId (or a point), anchor: side or offset on the target, label: short title, body: markdown text, category, number, position: where the note sits on the canvas, author: user | agent, created }`. Stored in the page file under `annotations` (serde default; old pages load unchanged).
|
||||
* **Categories** are project-level, editable: default `Business rule`, `Behaviour`, `Content`, `Data`, `Accessibility`, `Dev note`, each with a colour (theme palette) and icon.
|
||||
* Annotations survive moves and resizes of the target (anchor is relative); deleting the target keeps the annotation detached with a warning badge.
|
||||
|
||||
## Classification drives the look (user, 2026-10-21)
|
||||
|
||||
* The **category is the only styling input**. Icon, colour, badge shape and default label prefix come from the category; an annotation stores no icon or colour of its own. Changing a category's colour or icon restyles every note in it.
|
||||
* Categories carry a short **type key** used everywhere (`rule`, `behaviour`, `content`, `data`, `a11y`, `dev`); custom categories get a key too.
|
||||
* **Hiding information**, three levels, all undo-free view state (not saved in the design):
|
||||
* per note: collapse to its number badge only (click the badge to expand);
|
||||
* per category: show/hide from the category filter;
|
||||
* global (⌘⇧A cycles; ⇧A is Add auto layout): full notes → badges only → hidden.
|
||||
* Exports and Present mode use "hidden" unless chosen otherwise.
|
||||
|
||||
## Templates (user, 2026-10-21)
|
||||
|
||||
People annotate differently per project and even per page (a product spec, a dev handoff, an accessibility review). A **template** says which kinds of notes are wanted and what each should contain.
|
||||
|
||||
* `AnnotationTemplate { id, name, categories: [ { key, enabled, prompt: short guidance for what this note should say, fields: [name] (optional structured lines, e.g. "Applies when", "Owner") } ], display: full | badges }`.
|
||||
* Stored in `design.json` (project-level list); the **project default** is one template, and a **page can override** it (`page.annotation_template`, serde default). New categories added in a template become available project-wide.
|
||||
* **Built-in templates** (editable copies, not locked): `Product spec` (rule, behaviour, content, data), `Dev handoff` (behaviour, data, dev, a11y), `Accessibility review` (a11y with fields "Issue", "WCAG", "Fix"), `Content review` (content with "Tone", "Max length"), `Minimal` (rule only).
|
||||
* **UI**: the Annotations side view header has a template picker (project default or this page) and a "Customize…" modal: toggle categories, edit name/colour/icon/key, edit each category's guidance and fields, duplicate/reset a template. The Annotate popover only offers the template's enabled categories, and shows the fields as labelled lines.
|
||||
* **Ask AI**: an "Annotate with AI" button in the side view (and a chat suggestion "Annotate this page") sends a prompt to the chat: annotate the selected frame or the page using the active template. The agent reads the template with one cheap call.
|
||||
* MCP: `get_annotation_template {pageId?}` returns compact `[[key, guidance, fields]]` for the enabled categories (a few hundred tokens); `annotate` rejects types the page's template disables (error lists the allowed keys) and accepts `fields` as `"Name: value"` lines inside `text`. `set_annotation_template {pageId?, template}` switches it.
|
||||
|
||||
## Cheap for agents
|
||||
|
||||
* One tool call can add many notes: `annotate {notes: [{node, text, type}]}` where `node` is a node id or a layer name on the active page, `text` is the note (first line becomes the label, the rest the body), `type` is the category key (default `rule`). No positions, icons or colours: placement is automatic (beside the target, outside its frame, stacked without overlap).
|
||||
* `get_annotations` returns compact rows `[n, node, type, text]`, not full objects; `{full: true}` for everything.
|
||||
* `update_annotation` / `delete_annotations` take ids or numbers ("frame Sign in #3").
|
||||
* The tool description stays under ~60 words.
|
||||
|
||||
## Canvas
|
||||
|
||||
* A note is a small card (number badge, label, first lines of the body) connected to its target by a **leader line**: elbow or straight, ending in a dot or arrow on the target's anchor, plus a thin outline around the target while hovered.
|
||||
* Notes are draggable; the line re-routes (reuse the connector router, `src/connector.rs`).
|
||||
* **Toggle**: View → Annotations (⌘⇧A) shows or hides all; a filter per category; never drawn in Present mode or exports (notes live only in the editor overlay; an export option to include them is a later item).
|
||||
* Numbering is per frame, in reading order, so a frame reads "1, 2, 3…".
|
||||
|
||||
## UI
|
||||
|
||||
* **Annotate tool** (N; A is the Frame tool): click a layer, type the note; or select layers → right-click → Annotate.
|
||||
* **Annotations side view**: list per frame with number, category chip, label; click to select and zoom; filter by category; edit inline.
|
||||
* **Inspector**: an Annotations section for the selected layer.
|
||||
|
||||
## Agents and handoff
|
||||
|
||||
* MCP: `annotate` (batch, see "Cheap for agents"), `get_annotations`, `update_annotation`, `delete_annotations`; classified in `edits_doc`.
|
||||
* `get_design_spec` and `get_jsx` include annotations as comments (`// 1 Business rule: …`), so code generated from a frame carries the rules.
|
||||
* Chat: the `@ Selection` context includes the selected layers' annotations; the agent can be asked to "annotate this screen".
|
||||
|
||||
## Mock-ups
|
||||
|
||||
`PaperCraft` design file, page `Annotations`: `Canvas / Annotated frame` (several categories, leader lines, numbering), `Canvas / Annotations hidden`, `Panel / Annotations` (344x1440), `Inspector / Annotation` (344x1440), `Popover / New annotation`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
* Add, edit, move, hide/show, filter, delete annotations; undo for each.
|
||||
* Moving or resizing a target keeps the leader line attached; deleting it detaches the note with a badge.
|
||||
* Old projects load unchanged; page files without `annotations` stay byte-identical on save.
|
||||
* Exports and Present mode never include annotations.
|
||||
* MCP tools round trip; `get_design_spec` includes them.
|
||||
* Screenshot check against the mock-ups; tests for model, anchoring, numbering, serialization, MCP.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Tasks
|
||||
|
||||
Stop on failure. UI tasks go to Sonnet, never Haiku. Agents clean up after themselves.
|
||||
|
||||
## Wave 1 (with chat-agents wave 1)
|
||||
|
||||
- [x] 1. **Mock-ups** on page `Annotations`. Sonnet.
|
||||
|
||||
## Wave 2 (parallel with chat-agents wave 2)
|
||||
|
||||
- [x] 2. **Model and storage**: `Annotation`, categories in `design.json`, page field (serde default, byte-identical save for old pages), anchoring relative to target, numbering per frame in reading order, undoable edits through `Editor`. Tests. Sonnet.
|
||||
- [x] 3. **MCP tools** `add_annotation`, `get_annotations`, `update_annotation`, `delete_annotations`; `get_design_spec` / `get_jsx` include them as comments; classified in `edits_doc`. Codex Sol (Sonnet if unavailable).
|
||||
|
||||
- [x] 3b. **Templates**: model in `design.json` + page override, built-ins, `get_annotation_template` / `set_annotation_template`, `annotate` validates against the page's template. Sonnet (after 3).
|
||||
|
||||
## Wave 3 (after wave 2 and the mock-ups)
|
||||
|
||||
- [x] 4. **Canvas**: note cards, leader lines (connector router), drag, toggle ⇧A, category filter, hidden in Present and exports unless asked. Sonnet.
|
||||
- [x] 5. **Annotate tool, side view (template picker, Customize modal, Annotate with AI), inspector section, popover**. Sonnet.
|
||||
|
||||
## Wave 4
|
||||
|
||||
- [x] 6. **Review** (Codex Sol, read-only) and fixes (Sonnet); chat `@ Selection` context includes annotations.
|
||||
- [x] 7. **Docs** (Haiku): `docs/annotations.md`, README, `docs/agents.md`, ROADMAP, PROGRESS.
|
||||
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,38 @@
|
||||
# Chat agents: any agent, found reliably, and a picker that scales
|
||||
|
||||
## Why (user report, 2026-10-21)
|
||||
|
||||
* Claude Code and Codex show "Not found on PATH" although both are installed in `~/.local/bin`. Root cause: an app opened from the Dock or Finder gets the minimal macOS PATH (`/usr/bin:/bin:/usr/sbin:/sbin`); `launchctl getenv PATH` is empty. The user's login shell also finds `pi`, `opencode`, `gemini`, `goose` (`/opt/homebrew/bin`) and `cursor-agent` (`~/.local/bin`).
|
||||
* The setup state offers `npm install -g …` as the way to install agents. Claude Code and Codex have their own installers; PaperCraft should not tell people how to install agents at all.
|
||||
* Only two agents are supported; the user wants every popular agent, generically.
|
||||
* The agent picker popup has no shadow and overlaps the setup card; the Settings → Chat page has broken spacing, padding and height (fields not aligned, the page bottom cut off).
|
||||
* Sending a message did nothing: no `chats/` folder was created, no error was shown.
|
||||
* The composer allows one `@ Selection` chip; the user wants several.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Find agents like the shell does.** At startup (native, off the UI thread), resolve the login shell PATH once: `$SHELL -l -c 'printf %s "$PATH"'` with a 3 s timeout; merge with the process PATH and common folders (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `~/.cargo/bin`, `~/.bun/bin`, `~/.npm-global/bin`, Volta and nvm current bins). Use it for agent detection and for spawning agents (set `PATH` on the child). A path override in Settings still wins.
|
||||
2. **ACP backend** (`src/chat/acp.rs`): a generic Agent Client Protocol client (JSON-RPC 2.0 over the agent's stdio): `initialize`, `session/new` with `cwd` = project folder and `mcpServers` = PaperCraft's server (HTTP entry when the agent advertises `mcpCapabilities.http`, else a stdio entry running the bundled `papercraft-mcp` bridge with `PAPERCRAFT_MCP_PORT`), `session/prompt` with text (and image) content blocks, streaming `session/update` notifications mapped to our `Event`s (agent message chunks → Text, thought chunks → Thinking, tool_call / tool_call_update → ToolStart/ToolEnd, plan → Plan items), `session/cancel` for Stop, `session/request_permission` answered from our approval flow (papercraft tools allowed; destructive ones and any non-papercraft tool ask the user), `session/load` to resume when supported. The PaperCraft skill text goes in as the first prompt's leading context (ACP has no system prompt field).
|
||||
3. **Agent registry, as data** (`src/chat/agents.rs`): `{ id, name, detect: binary name, launch: command + args, kind: Native | Npx, docs_url }`. Native entries are found on the resolved PATH: Gemini CLI (`gemini --acp`), Cursor (`cursor-agent acp`), OpenCode (`opencode acp`), Goose (`goose acp`), Kimi (`kimi acp`), Devin (`devin acp`), Junie (`junie --acp=true`), Mistral Vibe (`vibe-acp`), Hermes (`hermes acp`), OpenHands (`openhands acp`), Stakpak (`stakpak acp`), VT Code (`vtcode acp`), Poolside (`pool acp`), Droid (`droid exec --output-format acp-daemon`). `npx`-launched entries (Qwen Code, Kilo, Cline, Auggie, Copilot CLI, Qoder, Codebuddy, Pi via `pi-acp`, Claude/Codex via their ACP adapters) are listed as available but off until the user enables them once, because enabling runs code fetched by `npx`. Claude Code and Codex keep their direct adapters (already proven) and are the default for them. Exact flags are verified against each installed agent's `--help` where installed; the rest keep the documented command and fail with the agent's own error.
|
||||
4. **Custom agent**: name + command + args (ACP over stdio). No provider lists beyond the registry.
|
||||
5. **No install instructions.** A missing agent shows "Not found" plus "Set path…" and a link to the agent's own docs (`docs_url`), nothing else.
|
||||
6. **Agent picker becomes a panel**, not a dropdown: opened from the agent chip, sized like the chat column (overlay with shadow and a backdrop), sections Detected, Enable (npx), API endpoints, Custom, a search field, and per row: icon, name, status chip (Ready, Needs enable, Not found, Signed out if detectable), version, path. Clicking a row selects it for this chat.
|
||||
7. **Settings → Chat** redesign with the same list (one row per agent with a details disclosure: path override, args override, enable), API endpoints as compact rows with an edit disclosure, consistent 16/24 px padding, labels aligned on one grid, the page scrolls instead of being cut off.
|
||||
8. **Setup state** in the chat column: one card "Connect an agent" listing detected agents to pick from (usually already some), plus "Add an API endpoint" and "Add custom agent"; no install commands.
|
||||
9. **Send bug**: reproduce, find the root cause, fix, and add a regression test; any start failure must show an error row in the thread (never a silent no-op) and keep the typed text.
|
||||
10. **Multiple selections**: each `@` adds a chip (selection at that moment, labelled by layer names); chips stack, can be removed one by one; all are sent as context lines with node ids.
|
||||
|
||||
## Not in scope
|
||||
|
||||
* Installing agents.
|
||||
* ACP terminal/file-system client methods beyond what PaperCraft needs (answer `fs/*` and `terminal/*` requests with "not supported" unless the agent requires them to work; most agents use their own file tools).
|
||||
|
||||
## Acceptance
|
||||
|
||||
* With the app launched from the Dock: Claude Code, Codex, Pi, OpenCode, Gemini, Goose and Cursor are detected on this machine.
|
||||
* A turn with Gemini CLI, OpenCode, Goose and Cursor (each installed here) creates an artboard through PaperCraft's tools via ACP; a Pi turn works after enabling `pi-acp`.
|
||||
* Stop cancels an ACP turn within 3 s.
|
||||
* Picker panel, Settings → Chat and the setup state match new mock-ups at 1440p and 1280x800; nothing clipped; shadows on overlays.
|
||||
* Sending a message always either starts a turn or shows an error row.
|
||||
* Three `@` chips can be added and removed independently.
|
||||
* Tests: PATH merge, registry detection with a fake PATH, ACP JSON-RPC handshake and update mapping against a fake agent process (recorded fixture), permission flow, cancel.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Tasks
|
||||
|
||||
Stop on failure. UI tasks go to Sonnet (or Codex Sol), never Haiku. Each agent cleans its temp homes, logs and drivers when done; one shared `target/`.
|
||||
|
||||
## Wave 1 (parallel)
|
||||
|
||||
- [x] 1. **Mock-ups** on page `Chat agents` (agent panel, setup, Settings / Chat, multiple selections). Sonnet.
|
||||
- [x] 2. **PATH resolution, no install hints, send bug, multiple `@` chips** (spec items 1, 5, 9, 10). Sonnet.
|
||||
|
||||
## Wave 2 (after wave 1)
|
||||
|
||||
- [x] 3. **ACP backend** `src/chat/acp.rs`: JSON-RPC client, session/new with PaperCraft's MCP server (HTTP or the `papercraft-mcp` stdio bridge), prompt, update mapping, permission flow, cancel, load. Fake-agent fixture tests; real turns with Gemini CLI, OpenCode, Goose and Cursor (installed here). Sonnet.
|
||||
- [x] 4. **Agent registry and custom agents** `src/chat/agents.rs`: data table, detection on the resolved PATH, npx enable state, custom agent entries in ChatPrefs, BackendKind/Backend for ACP agents. Codex Sol (Sonnet if unavailable).
|
||||
|
||||
## Wave 3 (after wave 2 and the mock-ups)
|
||||
|
||||
- [x] 5. **Agent panel and setup state** in the chat column (spec items 6, 8). Sonnet.
|
||||
- [x] 6. **Settings → Chat redesign** (spec item 7). Sonnet.
|
||||
|
||||
## Wave 4
|
||||
|
||||
- [x] 7. **Review** (Codex Sol, read-only), fixes (Sonnet).
|
||||
- [x] 8. **Docs** `docs/chat.md` (agents, ACP, custom), README. Haiku.
|
||||
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 584 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,113 @@
|
||||
# Chat layout: design by conversation
|
||||
|
||||
## Why
|
||||
|
||||
Today an agent drives PaperCraft from outside: a terminal running Claude Code or Codex, pointed at the MCP server. The app shows a working pill, the agent runs and their review, but the conversation itself lives elsewhere. OpenDesign and Cursor's agent view put the conversation next to the result. PaperCraft already has everything behind that: 61 tools, agent runs with auto-versions and review, skills, design systems and libraries.
|
||||
|
||||
## Decisions (user, 2026-10-21)
|
||||
|
||||
* Backend: **both** local agent CLIs (Claude Code, Codex) and a bring-your-own-key Anthropic API path.
|
||||
* Layout: a separate **Chat layout**, chat column left, live canvas right, switched from the top bar (`Design | Chat`).
|
||||
* Conversations live **in the project** (`chats/`), linked to the agent runs they produce.
|
||||
* Mock-ups first.
|
||||
|
||||
## What exists today
|
||||
|
||||
* MCP server on `127.0.0.1:<PAPERCRAFT_MCP_PORT>` (default 29980), loopback only, rejects `Origin` headers (`src/mcp/server.rs`). Every tool runs on the UI thread through `App::run_mcp_calls` and `mcp::exec::call`.
|
||||
* Agent runs (`src/runs.rs`): editing calls grouped into runs with auto-versions, review, revert, the finish toast.
|
||||
* Tool list and schemas: `mcp::server::tool_list()`, `crate::mcp::tools::edits_doc`.
|
||||
* Skills: `skills/papercraft`, `skills/papercraft-design`, `skills/papercraft-design-system` (in repo) and the installed copies under `~/.agents/skills/`.
|
||||
* No HTTP client for outbound API calls except `reqwest` (already in `Cargo.lock` through rmcp) and `curl` used by the CLI's icon download.
|
||||
|
||||
## Layout
|
||||
|
||||
* **Mode switch** in the top bar: `Design | Chat` (segmented control), shortcut ⌘J. The mode is per window and remembered per project.
|
||||
* **Chat mode**: left column 420–640 px wide (resizable, default 480), canvas on the right with the normal top bar, zoom and a slim tool strip; the layers, inspector and side views are hidden (a toggle brings the inspector back as a narrow overlay).
|
||||
* **Chat column**, top to bottom:
|
||||
* header: chat title (rename), chat list menu (new chat, switch, delete with confirm), agent picker chip (`Claude Code · Opus 5.5`, `Codex · gpt-6.1-sol`, `API · Opus 5.5`) and a settings icon;
|
||||
* message list: user messages; assistant text streamed as it arrives (markdown: paragraphs, lists, code, inline code, links); thinking/progress shown as a collapsed one-line "Thinking…" row when the backend provides it; **tool-call cards**: verb + target ("Drawing layout · Card", from `agent_verb`), status (running spinner, done, failed with the error), collapsible args/result JSON, a thumbnail for `get_screenshot` results; a **run card** when an agent run ends (`Agent finished, N changes` with Review, Revert, Accept, reusing the run review); errors as a red row with Retry;
|
||||
* composer: multi-line input (⏎ sends, ⇧⏎ newline), attachment chips (current selection as `@Frame name` chips added by a button or ⌘L, images by paste/drag, the active page), a Stop button while a turn runs, a model/effort menu for the API backend;
|
||||
* empty state: suggestions built from the project ("Design a sign-in screen using this project's variables", "Lint this page and fix warnings", "Make a dark mode").
|
||||
* **Patterns from shadcn AI Elements** (ui.shadcn.com/docs/ai-elements; React components, so only their patterns and names are reused, built from `src/ui/kit.rs`):
|
||||
* **Chat** thread with a message scroller that sticks to the bottom while streaming and keeps your place when you scroll up (a "Jump to latest" pill appears).
|
||||
* **Message**: user and assistant turns; assistant actions on hover (copy, retry).
|
||||
* **Reasoning**: collapsible, streams while open, collapses to "Thought for 12s" when done.
|
||||
* **Tool**: one card per call, states pending / running / done / error, input and output sections collapsible; a screenshot output shows inline.
|
||||
* **Task / Chain of Thought**: consecutive tool calls in one assistant turn group into a step list ("Built the card · 5 steps") that expands.
|
||||
* **Plan / Queue**: when the agent keeps a todo list (Claude Code `TodoWrite`, or the API backend's plan), show remaining and completed items pinned above the composer.
|
||||
* **Confirmation**: approve or deny before destructive tools run (`delete_pages`, `delete_nodes` on more than 20 layers, `disable_library`, `import_design_system`); API backend pauses the loop; CLI backends rely on the CLI's own permission prompt where it exists, else the tool stays allowed and the run can be reverted.
|
||||
* **Checkpoint**: each agent run's auto-version shows as a checkpoint in the thread; "Restore to here" reverts the runs after it (reuses `runs::revert`).
|
||||
* **Context**: a small meter in the header with tokens used of the model's context window (API and Claude Code report usage).
|
||||
* **Model Selector**: backend + model menu and an effort menu.
|
||||
* **Prompt Input**: multi-line field, attachments (selection, images, page), send / stop.
|
||||
* **Suggestion**: one-click prompts in the empty state and after a run ("Lint this", "Make a dark mode", "Review changes").
|
||||
* **Subagent**: a nested, collapsible block when a CLI agent spawns a sub-agent.
|
||||
* **Code Block**: fenced code in replies with a copy button.
|
||||
* Not used: Sources, Inline Citation, File Tree, Terminal, Stack Trace, Test Results (not design work).
|
||||
* **Canvas side**: follows the agent: when a tool call targets a node, the canvas pans to it unless the user moved the camera in the last 10 s; the run overlay (green/yellow/red outlines) shows while a run is open.
|
||||
* Design mode keeps working as today; the chat keeps running in the background when you switch.
|
||||
|
||||
## Backends
|
||||
|
||||
All backends drive the same tools, so runs, undo, review and revert work the same.
|
||||
|
||||
### Local agent CLIs
|
||||
|
||||
* **Claude Code**: spawn `claude -p` with streaming JSON in and out (`--input-format stream-json --output-format stream-json --verbose`), an MCP config pointing at the app's server (`--mcp-config` with `{"mcpServers":{"papercraft":{"type":"http","url":"http://127.0.0.1:<port>/mcp"}}}`), the papercraft tools allowed (`--allowedTools "mcp__papercraft__*"`), working directory = the project folder, and the system prompt appended with the PaperCraft skill text. Resume with `--resume <session_id>` (stored on the chat). Exact flags are checked against `claude --help` at implementation time and wrapped in one adapter.
|
||||
* **Codex**: spawn `codex exec --json` with the server added through `-c mcp_servers.papercraft.url=...` (or the current equivalent, checked against `codex exec --help`), resume with its session id. Same adapter shape.
|
||||
* **Detection**: Settings → Chat lists the CLIs found on `PATH` (plus a path override) with their version; a missing CLI shows install hints, never an error dialog.
|
||||
* **Events**: each adapter turns the CLI's JSON lines into one internal event stream: `Text(delta)`, `Thinking(delta)`, `ToolStart{id,name,args}`, `ToolEnd{id,result|error}`, `TurnEnd{usage?}`, `Error`. Tool calls also arrive through the MCP server, so the card status is confirmed by the app itself (`log_agent`), not only by the CLI's report.
|
||||
* **Stop**: sends an interrupt (stdin control message where supported, else SIGINT, then kill after 3 s). The run so far stays reviewable.
|
||||
* **Security**: the CLI runs with the user's own permissions and logins; PaperCraft passes no keys. Only papercraft tools are pre-allowed; other tools follow the CLI's own permission settings (non-interactive: denied).
|
||||
|
||||
### Anthropic API (bring your own key)
|
||||
|
||||
* Raw HTTPS to `POST https://api.anthropic.com/v1/messages` with `stream: true` (no Rust SDK exists), using `reqwest` (already a dependency).
|
||||
* **Key**: entered in Settings → Chat, stored in the macOS Keychain (Linux: Secret Service; Windows: Credential Manager) through the `keyring` crate, never in project files or logs. `ANTHROPIC_API_KEY` in the environment is used when no key is stored.
|
||||
* **Model**: default `claude-opus-5-5`; menu also offers `claude-sonnet-5-5` and `claude-haiku-5-5`. Thinking adaptive (default on these models), `output_config.effort` set explicitly (default `medium`, menu low/medium/high), `thinking.display: "summarized"` so the Thinking row has text. Server-side refusal fallback on (`fallbacks: "default"` with beta `server-side-fallback-2026-07-01`; for Haiku, show the refusal instead).
|
||||
* **Tools**: the app's own tool list (`tool_list()` converted to Messages API `tools`: name, description, input_schema), with `eager_input_streaming: true`; inputs are parsed tolerantly, validated against the schema before running, and run through `mcp::exec::call` on the UI thread like any MCP call (so runs and undo behave the same). Several `tool_use` blocks in one turn run in order and all `tool_result` blocks go back in one user message; failures go back as `is_error: true`. No forced `tool_choice` (not supported on these models).
|
||||
* **Loop**: stream → collect `tool_use` → run → append `response.content` unchanged (thinking blocks included) + tool results → next request, until `stop_reason` is `end_turn`; `max_tokens` → continue once with a note; `refusal` → show the category; `pause_turn` → continue. Max 40 tool rounds per user message (then stop and say so).
|
||||
* **Caching**: `cache_control` breakpoints on the tool list and on the system prompt (skill text + project context), stable ordering so they hit; per-turn volatile context (selection, active page) goes in the user message, not the system prompt. `usage.cache_read_input_tokens` is logged to verify.
|
||||
* **Images**: `get_screenshot` results go back as image blocks so the model sees what it made; user-attached images too.
|
||||
* **Cost line**: per turn input/output/cache tokens and an estimated cost from a small price table, shown under the turn in a muted row.
|
||||
* Implementers read the bundled claude-api skill files `curl/examples.md`, `shared/tool-use-concepts.md`, `shared/prompt-caching.md`, `shared/model-migration.md` (Opus 5.5 section) before writing this adapter.
|
||||
|
||||
### Custom endpoints (generic, no provider list)
|
||||
|
||||
The API backend is not tied to Anthropic. Settings → Chat holds one or more **endpoints**, each `{ name, format, base_url, model, key }`:
|
||||
|
||||
* `format`: `anthropic` (Messages API, `POST {base}/v1/messages`, `x-api-key`) or `openai` (Chat Completions, `POST {base}/chat/completions`, `Authorization: Bearer`, streaming SSE `choices[].delta`, tools as `{"type":"function","function":{name,description,parameters}}`, `tool_calls` with `arguments` streamed as string fragments, results as `role: "tool"` messages).
|
||||
* `base_url`: free text (e.g. `https://api.anthropic.com`, any Anthropic-compatible URL, `http://127.0.0.1:11434/v1`). `model`: free text. `key`: optional (local servers need none), stored in the OS keychain under the endpoint's base URL, never in settings files.
|
||||
* Claude-only request fields (`output_config.effort`, `thinking.display`, `fallbacks` and its beta header, `eager_input_streaming`) are sent only when the base URL host is `api.anthropic.com`; other Anthropic-format endpoints get a plain request (adaptive thinking omitted), and are tolerant of missing `usage`, `thinking` blocks, or `cache_*` fields.
|
||||
* The cost line shows a price only for models in the built-in Claude price table; otherwise tokens only (or nothing when the server reports no usage).
|
||||
* No provider names, presets or logos in the app. A default endpoint `Anthropic` (`anthropic`, `https://api.anthropic.com`, `claude-opus-5-5`) exists so the first-run path stays one key away.
|
||||
* The agent picker lists every configured endpoint by its name.
|
||||
|
||||
### Not in scope (v1)
|
||||
|
||||
* Named provider presets.
|
||||
* Multiple chats running at once in one project (one active turn per project).
|
||||
* Voice, file uploads other than images.
|
||||
|
||||
## Storage
|
||||
|
||||
* `chats/<id>.json` in the project folder: `{ id, title, created, updated, backend: {kind, model?, effort?, cli_session?}, messages: [ {role, text, tools:[{id,name,args,result,error,ms}], run_id?, usage?, at} ] }`. API backend also keeps the raw `messages` array (for exact replay, including thinking blocks) in `chats/<id>.api.json`.
|
||||
* Chats sync with the project (relay). Deleting a chat deletes both files after a confirm.
|
||||
* The project's newest chat opens when entering Chat mode.
|
||||
|
||||
## MCP and CLI
|
||||
|
||||
* No new tools needed. `papercraft-cli` is unchanged.
|
||||
|
||||
## Acceptance
|
||||
|
||||
* Chat mode toggles from the top bar and ⌘J; layout matches the mock-ups at 1440p and 1280x800.
|
||||
* With Claude Code installed: "Create a 390x844 artboard named Test with a centred title" streams text, shows tool cards for `create_artboard` and `write_html`, the canvas follows, a run card appears at the end, Review/Revert work; a follow-up message continues the same CLI session.
|
||||
* Same with Codex.
|
||||
* With an API key: the same prompt works through the API backend; thinking row shows a summary; cost line shows tokens; a second turn reads from cache (`cache_read_input_tokens > 0`).
|
||||
* Stop during a long turn ends it within 3 s; tools already applied stay and are revertible.
|
||||
* Missing CLI and missing key show setup states, not errors; a bad key shows the API error message.
|
||||
* Chats persist in `chats/` and reopen after restart; a project without `chats/` loads unchanged.
|
||||
* The key never appears in project files, logs or crash output (test greps the project folder and the log after a run).
|
||||
* Tests: event parsing for each adapter from recorded JSON fixtures (Claude Code stream-json, Codex --json, Anthropic SSE incl. thinking, tool_use with input_json_delta, refusal, max_tokens); tool-schema conversion; chat file round trip; the API loop with a fake HTTP server (two tool rounds then end_turn).
|
||||
* `cargo test --no-fail-fast`, wasm and Windows checks pass (chat backends are native-only; the web build shows Chat mode disabled with a note).
|
||||
@@ -0,0 +1,33 @@
|
||||
# Tasks
|
||||
|
||||
Stop on failure. Tasks in one wave touch different files and run in parallel; a wave starts when the previous one passes `cargo test --lib`. A read-only review after waves 2 and 4; real findings are fixed before moving on.
|
||||
|
||||
## Wave 0: design (parallel with wave 1)
|
||||
|
||||
- [x] 0. **Mock-ups** in the `PaperCraft` design file, new page `Chat`: `Chat / Empty`, `Chat / Streaming` (text + tool cards + thinking row), `Chat / Run finished` (run card, canvas overlay), `Chat / Agent picker` (menu open, CLIs found/missing, API), `Chat / Setup` (no CLI, no key), `Settings / Chat`, at 2560x1440 and a 1280x800 variant of `Chat / Streaming`. The user's app had another project open, so this ran through the 0.5.0 offline CLI on the `PaperCraft` project (each call refuses if the app has it open); never `open_file` in the user's app.
|
||||
|
||||
## Wave 1: model and storage (parallel)
|
||||
|
||||
- [x] 1. **Chat model and storage** (new `src/chat/mod.rs`, `src/chat/store.rs`): types from the spec, `chats/` load/save/list/delete, legacy project without `chats/`. Tests: round trip, listing order, delete.
|
||||
- [x] 2. **Event stream and adapters' parsing** (new `src/chat/events.rs`, `src/chat/claude_code.rs`, `src/chat/codex.rs`, `src/chat/anthropic.rs` parsing half): the internal `Event` enum and pure parsers from recorded fixtures (`tests/fixtures/chat/`). Record real fixtures by running the CLIs once on a scratch project with a throwaway prompt (no user data); for the API, build SSE fixtures from the documented event shapes. Tests per backend.
|
||||
|
||||
## Wave 2: backends (after wave 1; 3, 4, 5 parallel)
|
||||
|
||||
- [x] 3. **Claude Code process** (`src/chat/claude_code.rs`): detect, spawn with MCP config and allowed tools, stream stdin/stdout on a worker thread, resume, stop. Integration test gated on `claude` being on PATH (skipped otherwise).
|
||||
- [x] 4. **Codex process** (`src/chat/codex.rs`): same for `codex exec --json`. Gated test.
|
||||
- [x] 5. **Anthropic API loop** (`src/chat/anthropic.rs`, `keyring` dependency): key storage, request building (tools from `tool_list()`, caching, effort, thinking display, fallbacks), SSE streaming, tool loop through a channel to the UI thread (`mcp::exec::call`), usage and cost. Test with a fake HTTP server.
|
||||
|
||||
## Wave 3: UI (after wave 2 and the mock-ups; 6 then 7 and 8 parallel)
|
||||
|
||||
- [x] 6. **Chat layout and mode switch** (`src/ui/chat.rs`, `src/ui/mod.rs`): Design | Chat toggle, ⌘J, column + canvas layout, resizable column, remembered per project.
|
||||
- [x] 7. **Messages and composer** (`src/ui/chat.rs`): markdown text, thinking row, tool cards, run card (reuse run review actions), errors with Retry, composer with attachments, Stop, empty state suggestions, agent picker, chat list. Canvas follows tool targets.
|
||||
- [x] 8. **Settings → Chat** (`src/ui/settings.rs`): CLI detection and paths, API key (Keychain), default backend/model/effort.
|
||||
|
||||
## Wave 4: finish
|
||||
|
||||
- [x] 9. **End-to-end check** with each available backend on a scratch copy of a project (own app instance on its own port and `PAPERCRAFT_HOME`), screenshots against the mock-ups, key-leak grep.
|
||||
- [x] 10. **Docs and release notes**: `docs/chat.md`, README section and screenshot, `docs/agents.md`, ROADMAP row 44 done, PROGRESS entry; full test suite, wasm and Windows checks.
|
||||
|
||||
## Model per task
|
||||
|
||||
Claude Sonnet: 0, 2, 3, 5, 6, 7, 9. Claude Haiku: 1, 8, 10. Codex `gpt-6.1-sol`: 4 and the reviews (Claude Sonnet if Codex is unavailable).
|
||||
@@ -0,0 +1,30 @@
|
||||
# Design from the CLI without the app
|
||||
|
||||
## Why
|
||||
|
||||
`papercraft-cli call` needs the app running with a project open. Agents on a server, in CI, or on a machine where the app is closed cannot design at all. The code that runs every tool already works without a window: `SessionHost` (`src/mcp/exec.rs:52`) holds an `Editor` and a `Session` and has an offscreen renderer (`Headless`) for screenshots and PNG export. Tests drive it today.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Offline calls.** `papercraft-cli call <tool> [JSON]` runs in-process on a `SessionHost` when the app is not reachable, or always with `--offline`. Projects come from the same store (`$PAPERCRAFT_HOME`, else the default folder). `--project <name|id|path>` opens a project first; without it, the most recently modified project is used, and `create_file` / `open_file` work as usual. Every editing call saves before exit.
|
||||
2. **Safety.** When the app is reachable, plain `call` keeps going through the app (it is the one with the open file). `--offline` while the app has the same project open is refused ("PaperCraft has this project open; close it or drop --offline"), so two writers never race on one folder.
|
||||
3. **Shell.** `papercraft-cli shell [--project X] [--offline]` is an interactive session that keeps one host for many calls:
|
||||
* a line is `<tool> <json>` (JSON may span lines until braces balance), or a command: `:help`, `:tools`, `:schema <tool>`, `:open <project>`, `:pages`, `:page <name|id>`, `:shot [nodeId] [file]`, `:undo`, `:redo`, `:save`, `:quit`;
|
||||
* `:watch on|off`: after every editing call, re-render the artboard last touched (or the page) to `preview.png` in the current folder; on iTerm2, WezTerm and kitty the image is also shown inline in the terminal;
|
||||
* history in `~/.papercraft_cli_history` when a line editor is available; plain stdin otherwise (works in pipes, so `papercraft-cli shell < script.pc` runs a script).
|
||||
4. **Preview without a GPU.** If no adapter is found, retry with the fallback (software) adapter; if that fails too, image tools return a clear error and every other tool still works.
|
||||
5. **Docs.** `docs/agents.md`: offline use, the shell, scripts. `papercraft-cli --help` updated.
|
||||
|
||||
## Not in scope
|
||||
|
||||
* A headless MCP server (`papercraft-mcp --offline`); the same host makes it a small follow-up.
|
||||
* A TUI canvas.
|
||||
|
||||
## Acceptance
|
||||
|
||||
* With the app closed: `papercraft-cli call create_file '{"name":"Demo"}'`, then `call create_artboard`, `call write_html`, `call get_screenshot -o out.png` produce a saved project and a PNG; opening the project in the app shows the design.
|
||||
* With the app open on project X: `call` goes to the app; `call --offline --project X` is refused; `--offline --project Y` works.
|
||||
* `papercraft-cli shell < script` runs a multi-line script and exits 0; a failing tool line exits 1 at the end and prints which line failed.
|
||||
* `:watch on` writes `preview.png` after each editing call.
|
||||
* Tests: offline call round trip on a temp store, refusal when a fake "app has it open" check says so, shell script parsing (multi-line JSON, commands).
|
||||
* `cargo test --no-fail-fast`, wasm `cargo check --lib` and Windows check pass (the CLI binary is native only).
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,104 @@
|
||||
# Variable collections, shared libraries and assets
|
||||
|
||||
## Why
|
||||
|
||||
Variables are a flat list of colors plus untyped tokens, copied into every page's `Document` and kept in step by hand (two writers fan out to every page, the remove buttons in `src/ui/confirm.rs` do not). There are no groups, no types beyond color, no aliases, and only fills can bind. Components work only on the page that holds the main: the Design tab lists the active page's components, so a `Design System` page's components are invisible everywhere else, and `create_instance` / `swap_instance` refuse other pages. Nothing can be shared between files except a one-off package import.
|
||||
|
||||
Goal: Figma's model, local-first. Collections with modes, slash groups, typed variables with aliases; components usable from any page; any file can publish itself as a library that other files enable and update; a proper place to see and edit all of it.
|
||||
|
||||
## Decisions (user, 2026-10-21)
|
||||
|
||||
* Libraries: **Figma-style publish**. A file publishes a versioned snapshot; consumers keep a copy and get "Updates available". Files keep working when a library is missing.
|
||||
* Variables: **collections + groups + types + aliases**, number variables bindable to geometry and type.
|
||||
* UI: a **Variables modal**, a **Libraries modal**, and an **Assets** side view. Design mock-ups first.
|
||||
|
||||
## What exists today (checked 2026-10-21)
|
||||
|
||||
* Storage: `Document.variables` (active colors), `modes`, `mode`, `tokens` (`src/model/document.rs:15`), one copy per page. Writers: `Tx::set_variable`, `remove_variable`, `rename_variable`, `set_token`, `remove_token`, `set_design_system`, `remove_mode`, `set_variable_in_mode`, `set_mode` (`src/history.rs`); UI `on_every_page` (`src/ui/design.rs:15`); MCP `set_variables`, `set_tokens`, `set_variable_mode`, `import_design_system`, `create_page` (`src/mcp/exec.rs`).
|
||||
* Binding: `Paint::Solid { color, var: Option<String> }` holds the CSS name; the color is baked in and `apply_variable` rewrites it. Renderer, SVG and PDF read the baked color only. Tokens are substituted as text when CSS is written (`css::set_variables_in_scope`), no link kept.
|
||||
* Readers: codegen (`:root`, `[data-mode]`, Tailwind, DTCG), `get_variables`, `tokens_css`, lint (`lint.rs` match and auto-link), `diff_pages`, inspector var picker.
|
||||
* Components: `Node.component`, `InstanceLink { main: NodeId, overrides }`; `Editor::fixup` syncs instances on the same `Document`. `import_components` copies mains between pages with no link back.
|
||||
* Project: `project.json` (version 2: v1 to v2 is a no-op manifest step; the version bump makes older apps refuse), `pages/<id>.json` (version 3, migration chain), `versions/`, `runs/`. Projects live in one store folder; relay library mode already syncs every project to every device.
|
||||
* Figma import ignores variables and does not link instances.
|
||||
|
||||
## Model
|
||||
|
||||
### Variables (project level, `design.json` in the project folder)
|
||||
|
||||
```text
|
||||
DesignSystem
|
||||
collections: [Collection]
|
||||
Collection { id, name, modes: [Mode { id, name }], default_mode, variables: [Variable] }
|
||||
Variable { id, name: "color/brand/primary", kind: Color | Number | String | Boolean,
|
||||
values: { mode_id: Literal | Alias(variable id) }, description, hidden: bool }
|
||||
```
|
||||
|
||||
* **Groups** are the `/` segments of the name (`color/brand/primary` sits in group `color/brand`). Renaming a group renames every variable under it.
|
||||
* **CSS name**: segments joined by `-`, sanitized (`--color-brand-primary`). Two variables with the same CSS name across all enabled collections are refused at write time.
|
||||
* **Aliases** point to a variable of the same kind in any collection, including a library's. Cycles are refused. A value resolves through aliases per mode.
|
||||
* **Active mode** is per page per collection (`PageFile.modes: {collection_id: mode_id}`, default = `default_mode`). A new page inherits the active page's choice.
|
||||
* **Numbers** carry no unit; they bind as px. **Strings** substitute as text (font family, shadow). **Booleans** bind to visibility.
|
||||
* **One source, one cache.** `design.json` is the only stored truth. Each page's `Document.variables` / `modes` / `mode` / `tokens` become a derived cache rebuilt from it (resolved with that page's modes, keyed by CSS name), so the renderer, codegen, lint, diff and export keep reading what they read today. Every writer goes through one design-system API that updates `design.json`, rebuilds every page's cache and re-applies bindings in one undo step per page.
|
||||
* **Migration** (project version 1 → 2): the first page's `variables` + `modes` become collection `Colors` (one mode per existing mode, else `Default`); `tokens` become collection `Tokens` (a px value such as `16px` becomes Number; anything else, including unitless `1.5`, stays String). Existing fill bindings keep working because CSS names do not change. Pages keep their fields as the cache. The manifest version is 2, so an older app refuses the project ("made by a newer PaperCraft") rather than write pages without bindings and never update `design.json`.
|
||||
|
||||
### Bindings
|
||||
|
||||
* Fills keep `Paint::Solid.var` (CSS name).
|
||||
* New `Node.bindings: BTreeMap<field, css name>` for `stroke` (color), `text-color`, `width`, `height`, `gap`, `padding` (all four or per side), `radius`, `font-size`, `line-height`, `opacity`, `visible` (boolean). A bound field is re-applied when the variable, an alias target or the page's mode changes, exactly like fills today. Detaching writes the resolved value and drops the binding.
|
||||
* Instances inherit bindings from mains; an overridden field drops it on the instance only.
|
||||
|
||||
### Components across pages
|
||||
|
||||
* Every main gets a stable `component_key` (created on `create_component`, migrated for existing mains).
|
||||
* `InstanceLink` gains `source: Option<Source>`: `None` = same page (current behaviour), `Page(page_id)`, `Library(library_id)`. `main` keeps pointing at the node id inside that source document.
|
||||
* Editing a main on page A re-syncs instances on every other page whose link is `Page(A)`, in the same commit (one undo step per touched page). Deleting a main leaves its instances as they are, marked "main missing" (Assets offers Detach or Restore).
|
||||
* `create_instance` and `swap_instance` accept a component from any page or enabled library (`componentId` as `pageId:num`, or `componentKey`).
|
||||
* `import_components` stays (copy) but is no longer needed for reuse.
|
||||
|
||||
### Libraries
|
||||
|
||||
* **Publish** (Libraries modal, or MCP `publish_library`): writes `library.json` in the file's project folder: `{ library_id (= project id), name, description, version (counter), published (ms), collections (non-hidden variables), components (every main not named with a leading `_` or `.`, with its subtree and a 256px PNG preview) }`. A diff against the previous publish is shown first ("3 variables changed, 1 component added").
|
||||
* **Available libraries** = every project in the store with a `library.json` (relay library mode makes other devices' libraries appear with no extra work).
|
||||
* **Enable** in a consumer copies the snapshot to `<consumer>/libraries/<library_id>.json` and records `{library_id, version}` in `design.json`. Library collections show read-only in the Variables modal (aliasable, bindable); library components show in Assets.
|
||||
* **Updates**: when the library's `version` is newer than the copy, the Libraries modal and a dot on the Assets tab say "Updates available". Review lists changed variables and components; **Update** replaces the snapshot, rebuilds caches and re-syncs `Library(id)` instances (instance overrides kept). Updates are never applied silently.
|
||||
* **Missing library**: the copy keeps everything working; the modal shows "Source not found" with Disable (bindings are detached to resolved values, instances detached) after a confirm.
|
||||
* **Disable** with uses left: confirm, then detach as above.
|
||||
|
||||
## UI
|
||||
|
||||
* **Variables modal** (from Design tab "Open variables", palette "Variables…", ⌘⌥V): left column collections (local, then each library, read-only) with their group tree; main area a table, one row per variable in the selected group, one column per mode; cells edit inline (swatch + hex, number, text, toggle) with an alias picker ("→ color/brand/primary"); add variable (type menu), add mode, add collection, rename and delete (confirm), search, a details pane (description, hidden from publishing, CSS name, where used count). Copy CSS / JSON / Tailwind move here.
|
||||
* **Libraries modal** (Share menu "Libraries…", palette, Assets header): "This file" (publish state, description, changes since last publish, Publish), "Libraries" (available libraries with Enable toggles, version, source status), "Updates" (per library review and Update).
|
||||
* **Assets side view** (replaces the Library tab icon slot, keeps My shapes as its last section): search; sections This file (grouped by page), each enabled library, My shapes; previews in a grid; click to place at the viewport centre, drag onto the canvas; context menu: Go to main, Detach all, Rename (local only). Instances whose main is missing show a warning row.
|
||||
* **Design tab** slims to: modes per collection for this page, a short variables summary with "Open variables", lint. The components list moves to Assets.
|
||||
* **Inspector**: every bindable field gets the variable picker (filtered by kind), a bound field shows the variable chip with Detach.
|
||||
|
||||
Mock-ups: `PaperCraft` design file, new page `Design libraries`, artboards `Modal / Variables`, `Modal / Libraries`, `Modal / Library updates`, `Panel / Assets` (344x1440 like the other panel states), `Inspector / Bound fields` (344x1440). Modals drawn at 2560x1440 window size and checked at 1280x800.
|
||||
|
||||
## MCP and CLI
|
||||
|
||||
* `get_variables` returns collections, groups, kinds, per-mode values and aliases (current formats keep working; DTCG nests groups).
|
||||
* `set_variables` gains `collection`, `type`, `mode`, `alias`, `description`, `hidden`; old calls (color by name) still work and land in the first collection.
|
||||
* New: `create_collection`, `delete_variables`, `bind_variable` (node field ← variable), `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`.
|
||||
* `get_components` lists every page and enabled library with `componentKey` and `source`.
|
||||
* Every new tool is classified in `edits_doc` (the existing test enforces it).
|
||||
|
||||
## Not in scope (v1)
|
||||
|
||||
* Text styles, effect styles, grid styles (roadmap row 9; next).
|
||||
* Component variants and properties (row 10).
|
||||
* Per-frame mode override (mode is per page).
|
||||
* Live links between files without publish.
|
||||
* Figma variable import (separate item; the model now has room for it).
|
||||
|
||||
## Acceptance
|
||||
|
||||
* Migration: a project with variables, modes and tokens opens as version 2 with collections `Colors` and `Tokens`; every existing fill binding still resolves; 0.4.0 refuses a migrated project with a clear "made by a newer PaperCraft" error instead of losing data.
|
||||
* Groups: renaming group `color/brand` renames its variables and every binding follows.
|
||||
* Aliases resolve per mode; a cycle is refused; deleting an aliased variable asks first and detaches dependents to resolved values.
|
||||
* Number bindings: changing `space/md` from 16 to 24 updates the gap of every bound frame on every page in one undo step per page; switching a page's mode updates its bound fields only.
|
||||
* Removing a variable or mode from the UI affects every page (fixes today's page-only removal).
|
||||
* An instance on page B of a main on page A updates when the main changes; undo on A and B each restores its side.
|
||||
* Publish → enable in another project → instance and binding work; change and republish → consumer shows Updates available → Update re-syncs instances and keeps overrides; deleting the library project leaves the consumer working with "Source not found".
|
||||
* Two libraries defining `--color-bg` cannot both be enabled without a clear error.
|
||||
* Variables modal, Libraries modal and Assets view match their mock-ups at 1440p and 1280x800 (screenshot check).
|
||||
* `cargo test --no-fail-fast`, wasm and Windows checks pass; web build loads.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Tasks
|
||||
|
||||
Each task is independently testable. Stop on failure. Tasks in the same wave touch different files and can run in parallel; a wave starts when the previous one passes `cargo test --lib`. After each code wave a read-only Codex `gpt-6.1-sol` review; real findings get fixed before the next wave.
|
||||
|
||||
## Wave 0: design (parallel with wave 1)
|
||||
|
||||
- [x] 0. **Mock-ups** in the `PaperCraft` design file, new page `Design libraries`: `Modal / Variables`, `Modal / Libraries`, `Modal / Library updates`, `Panel / Assets` (344x1440), `Inspector / Bound fields` (344x1440). Reuse the `Design System` page variables and components. Only through the user's running app after `get_basic_info` confirms the open file is `PaperCraft`; never `open_file` in the user's app.
|
||||
|
||||
## Wave 1: model (parallel)
|
||||
|
||||
- [x] 1. **Design-system model** (new `src/variables.rs`). Types from the spec (`DesignSystem`, `Collection`, `Mode`, `Variable`, `Kind`, `Value`), ids, CSS name, group helpers (list groups, rename group), alias resolution per mode with cycle check, duplicate CSS name check, `cache_for(page modes) -> (variables, modes, mode, tokens)` producing today's `Document` fields. Tests: groups, rename group, alias chain, cycle refused, duplicate refused, cache matches today's shape.
|
||||
- [x] 2. **Storage and migration** (`src/project/format.rs`, `src/project/mod.rs`, `src/session.rs`). `design.json` load/save next to `project.json`; project version 2 with a `PROJECT_STEPS` step building collections `Colors` and `Tokens` from the first page; `PageFile.modes` per collection; `component_key` for existing mains. Tests: v1 project migrates, round trip, a v2 project's page files still deserialize in the old reader shape.
|
||||
- [x] 3. **Bindings field** (`src/model/node.rs`, `src/model/paint.rs`). `Node.bindings`, `InstanceLink.source`, `Node.component_key`, all `serde(default)`. Tests: old pages load; serialization skips empty fields.
|
||||
|
||||
## Wave 2: wiring (after 1–3; 4 then 5 and 6 in parallel)
|
||||
|
||||
- [x] 4. **One write path** (`src/history.rs`, `src/editor/*`). Design-system edits update the project `DesignSystem`, rebuild every page's cache and re-apply fill and field bindings, one undo step per page; replace `on_every_page` and the page-only removals in `src/ui/confirm.rs`. Per-page mode switch. Tests: number binding change on two pages; mode switch only touches its page; undo per page.
|
||||
- [x] 5. **Field bindings applied** (`src/editor/structure.rs`, layout). Apply bound stroke, text colour, size, gap, padding, radius, font size, line height, opacity, visible; instance sync carries bindings; detach writes resolved values. Tests per field.
|
||||
- [x] 6. **MCP and exports** (`src/mcp/exec.rs`, `src/mcp/tools.rs`, `src/mcp/codegen.rs`, `src/design_system.rs`). Extended `get_variables` / `set_variables`, `create_collection`, `delete_variables`, `bind_variable`; DTCG nests groups; old calls still work. Tests: old and new call shapes, DTCG groups, `edits_doc` classification.
|
||||
|
||||
## Wave 3: components across pages (after wave 2)
|
||||
|
||||
- [x] 7. **Cross-page instances** (`src/editor/structure.rs`, `src/mcp/exec.rs`). `Source::Page` sync on main edit, create/swap from any page, "main missing" state. Tests: edit main on A updates B; undo per page; delete main leaves instance flagged.
|
||||
|
||||
## Wave 4: libraries (after wave 3; 8 then 9)
|
||||
|
||||
- [x] 8. **Publish and enable** (new `src/libraries.rs`). `library.json` writer with previews and publish diff, available list from the store, enable copies snapshot, library collections and components resolvable (`Source::Library`), duplicate CSS name refusal. Tests: publish, enable in second project, binding and instance work, conflict refused.
|
||||
- [x] 9. **Updates and missing sources** (`src/libraries.rs`). Version compare, review diff, update keeps overrides, missing library keeps working, disable detaches after confirm. MCP `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library`. Tests per path.
|
||||
|
||||
## Wave 5: UI (after wave 4 and the mock-ups; 10, 11, 12 in parallel, then 13)
|
||||
|
||||
- [x] 10. **Variables modal** (new `src/ui/variables.rs`). Screenshot check against `Modal / Variables`.
|
||||
- [x] 11. **Libraries modal** (new `src/ui/libraries.rs`). Screenshot check against `Modal / Libraries` and `Modal / Library updates`.
|
||||
- [x] 12. **Assets view** (`src/ui/library.rs` becomes Assets). Screenshot check against `Panel / Assets`.
|
||||
- [x] 13. **Design tab and inspector** (`src/ui/design.rs`, `src/ui/inspector.rs`, palette entries). Slim Design tab; variable picker on every bindable field. Screenshot check against `Inspector / Bound fields`.
|
||||
|
||||
## Wave 6: finish
|
||||
|
||||
- [x] 14. **Docs and verify**. `docs/design-system.md`, `docs/agents.md` (new tools, count), `ROADMAP.md` rows 7, 8, 12, `PROGRESS.md`; skills `skills/papercraft*` mention collections, bindings and libraries. Full `cargo test --no-fail-fast`, wasm and Windows checks, web build loads.
|
||||
|
||||
## Model per task
|
||||
|
||||
Claude Sonnet: 0, 1, 2, 4, 5, 7, 8, 10, 11, 12. Claude Haiku: 3. Codex `gpt-6.1-sol`: 9, 13, and the reviews (task 6 moved to Sonnet: Codex hit its usage limit on 2026-10-21). Codex `gpt-6-luna`: 14.
|
||||
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 178 KiB |
@@ -51,6 +51,7 @@ pub struct App {
|
||||
mcp: Option<(
|
||||
std::sync::mpsc::Receiver<crate::mcp::Call>,
|
||||
crate::mcp::server::Running,
|
||||
crate::mcp::server::Dispatch,
|
||||
)>,
|
||||
/// Groups the agent's editing calls into reviewable runs.
|
||||
runs: crate::runs::Tracker,
|
||||
@@ -715,7 +716,12 @@ impl App {
|
||||
dir.map(|p| p.dir.as_path()),
|
||||
&self.ed,
|
||||
);
|
||||
if self.st.chat.take_finish_run() {
|
||||
// A chat turn that edited is over: close its run now, not after the 30 s idle timeout.
|
||||
self.runs.finish(crate::project::format::now_ms(), &self.ed);
|
||||
}
|
||||
self.st.sync_runs(&mut self.runs);
|
||||
self.st.chat.tools = self.tool_dispatch().map(crate::chat::events::ToolDispatch);
|
||||
let Some(g) = self.gfx.as_mut() else { return };
|
||||
let input = g.egui.take_egui_input(&g.window);
|
||||
let mut l = frame::layout(&self.ctx, input, &mut self.ed, &mut self.st);
|
||||
@@ -1436,10 +1442,16 @@ fn physical_char(c: winit::keyboard::KeyCode) -> Option<char> {
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Sends a tool call to the UI thread like an MCP request (runs, undo and the agent log
|
||||
/// behave the same); for the chat's API backend (`TurnConfig::tools`).
|
||||
pub fn tool_dispatch(&self) -> Option<crate::mcp::server::Dispatch> {
|
||||
self.mcp.as_ref().map(|(_, _, d)| d.clone())
|
||||
}
|
||||
|
||||
/// Run the MCP tool calls that arrived since the last wake-up, then repaint.
|
||||
fn run_mcp_calls(&mut self) {
|
||||
let calls: Vec<crate::mcp::Call> = match &self.mcp {
|
||||
Some((rx, _)) => rx.try_iter().collect(),
|
||||
Some((rx, _, _)) => rx.try_iter().collect(),
|
||||
None => return,
|
||||
};
|
||||
if calls.is_empty() {
|
||||
@@ -1517,6 +1529,7 @@ fn start_mcp(
|
||||
) -> Option<(
|
||||
std::sync::mpsc::Receiver<crate::mcp::Call>,
|
||||
crate::mcp::server::Running,
|
||||
crate::mcp::server::Dispatch,
|
||||
)> {
|
||||
if std::env::var("PAPERCRAFT_MCP").is_ok_and(|v| matches!(v.as_str(), "0" | "off" | "false")) {
|
||||
return None;
|
||||
@@ -1526,10 +1539,10 @@ fn start_mcp(
|
||||
let dispatch: crate::mcp::server::Dispatch = std::sync::Arc::new(move |call| {
|
||||
tx.lock().unwrap().send(call).is_ok() && proxy.send_event(UserEvent::Mcp).is_ok()
|
||||
});
|
||||
match crate::mcp::server::spawn(crate::mcp::port(), dispatch) {
|
||||
match crate::mcp::server::spawn(crate::mcp::port(), dispatch.clone()) {
|
||||
Ok(running) => {
|
||||
log::info!("MCP server listening on {}", running.url());
|
||||
Some((rx, running))
|
||||
Some((rx, running, dispatch))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MCP server not started: {e}");
|
||||
@@ -1545,6 +1558,8 @@ fn prefer_x11(kernel_release: &str, display: Option<&std::ffi::OsStr>) -> bool {
|
||||
}
|
||||
|
||||
pub fn run(start: Start) -> Result<(), String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::chat::path::init();
|
||||
let store = Store::open(Store::default_root())
|
||||
.map_err(|e| format!("cannot create the projects folder: {e}"))?;
|
||||
let mut builder = EventLoop::<UserEvent>::with_user_event();
|
||||
@@ -1570,7 +1585,7 @@ pub fn run(start: Start) -> Result<(), String> {
|
||||
}
|
||||
let mut app = App::new(start, store);
|
||||
app.mcp = start_mcp(event_loop.create_proxy());
|
||||
app.st.agent_endpoint = app.mcp.as_ref().map(|(_, r)| r.url()).unwrap_or_default();
|
||||
app.st.agent_endpoint = app.mcp.as_ref().map(|(_, r, _)| r.url()).unwrap_or_default();
|
||||
event_loop.run_app(&mut app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
//! (`http://127.0.0.1:29980/mcp`, or `$PAPERCRAFT_MCP_PORT`).
|
||||
|
||||
use base64::Engine;
|
||||
use papercraft::cli::{self, Accum, Offline, Stmt};
|
||||
use papercraft::icons;
|
||||
use papercraft::mcp::{self, server};
|
||||
use papercraft::mcp::{self, Out, server, tools};
|
||||
use papercraft::project::Store;
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use serde_json::Value;
|
||||
use std::io::Read;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, IsTerminal, Read, Write};
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
const USAGE: &str = "\
|
||||
papercraft-cli: call the running papercraft app's tools from a shell
|
||||
@@ -20,6 +23,8 @@ USAGE:
|
||||
papercraft-cli schema <tool> a tool's full description and JSON input schema
|
||||
papercraft-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
|
||||
[-o|--out FILE] where to save an image result (default: <tool>.png, or {\"out\": \"FILE\"})
|
||||
[--offline] [--project NAME|ID|PATH] work without the app, see below
|
||||
papercraft-cli shell interactive session; reads lines from a pipe too (see below)
|
||||
papercraft-cli icons list installed and installable open icon sets
|
||||
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
|
||||
papercraft-cli icons search <words> [--set S] find `set:name` references (works offline)
|
||||
@@ -30,8 +35,23 @@ write_html. Sets are fetched from the npm registry with curl and tar into the ic
|
||||
($PAPERCRAFT_ICONS, else the user data dir) together with their licence file.
|
||||
|
||||
Text results go to stdout; image results are saved to a file and the path is printed.
|
||||
Exit status is 1 when the tool reports an error or the app isn't running.
|
||||
Start the app and open a project first: it serves the tools while open.
|
||||
Exit status is 1 when the tool reports an error.
|
||||
|
||||
With the app running and a project open, calls go to the app. Otherwise (app not running, or
|
||||
--offline) the tools run in this process on the project store ($PAPERCRAFT_HOME, else the
|
||||
default folder) and every editing call is saved. --project opens that project first; without it
|
||||
the most recently modified one is used (create_file and open_file work as usual). --offline is
|
||||
refused while the app has the same project open.
|
||||
|
||||
shell [--project X] [--offline]: one session, many calls. A line is `<tool> <json>` (JSON may
|
||||
span lines until its braces close) or a command:
|
||||
:help :tools :schema <tool> :open <project> :pages :page <name|id> :shot [nodeId] [file]
|
||||
:undo :redo :save :watch on|off :quit
|
||||
:watch re-renders the artboard last edited to preview.png after each editing call (shown inline
|
||||
on iTerm2, WezTerm and kitty). Blank lines and lines starting with # are skipped. Input is plain
|
||||
stdin (no line editing, no history; wrap with rlwrap if you want it). Piped input prints no
|
||||
prompt and exits 1 when any line failed, listing the failed line numbers on stderr:
|
||||
papercraft-cli shell < script.pc
|
||||
";
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
@@ -184,86 +204,489 @@ fn icons_cmd(args: &[String]) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(name: &str) -> ExitCode {
|
||||
fn schema(name: &str) -> bool {
|
||||
let Some(t) = server::tool_list().into_iter().find(|t| t.name == name) else {
|
||||
return fail(format!("no tool named {name} (see `papercraft-cli list`)"));
|
||||
eprintln!("papercraft-cli: no tool named {name} (see `papercraft-cli list`)");
|
||||
return false;
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::to_value(&t).unwrap_or(Value::Null))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
true
|
||||
}
|
||||
|
||||
async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, String> {
|
||||
let out = out.or_else(|| {
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
type Client = rmcp::service::RunningService<rmcp::service::RoleClient, ()>;
|
||||
|
||||
/// Where tools run: the app over HTTP, or this process.
|
||||
enum Backend {
|
||||
Online(tokio::runtime::Runtime, Client),
|
||||
Offline(Box<Offline>),
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// The tool's output and whether it succeeded. Errors reach the caller as text, like the
|
||||
/// app's own error results.
|
||||
fn call(&mut self, name: &str, args: &Value) -> Result<(Vec<Out>, bool), String> {
|
||||
match self {
|
||||
Backend::Offline(o) => Ok(match o.call(name, args) {
|
||||
Ok(r) => (r, true),
|
||||
Err(e) => (vec![Out::Text(e)], false),
|
||||
}),
|
||||
Backend::Online(rt, client) => rt.block_on(online_call(client, name, args)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn online_call(
|
||||
client: &Client,
|
||||
name: &str,
|
||||
args: &Value,
|
||||
) -> Result<(Vec<Out>, bool), String> {
|
||||
let mut p = CallToolRequestParams::new(name.to_string());
|
||||
match args {
|
||||
Value::Object(m) => p.arguments = Some(m.clone()),
|
||||
Value::Null => {}
|
||||
_ => return Err("arguments must be a JSON object".into()),
|
||||
}
|
||||
let r = client.call_tool(p).await.map_err(|e| e.to_string())?;
|
||||
let mut outs = Vec::new();
|
||||
// Blocks are read through their JSON form, so this doesn't depend on rmcp's enum layout.
|
||||
for block in r.content {
|
||||
let v = serde_json::to_value(&block).map_err(|e| e.to_string())?;
|
||||
outs.push(match v.get("type").and_then(Value::as_str) {
|
||||
Some("text") => Out::Text(v["text"].as_str().unwrap_or("").to_string()),
|
||||
Some("image") => Out::Image {
|
||||
data: base64::engine::general_purpose::STANDARD
|
||||
.decode(v["data"].as_str().unwrap_or(""))
|
||||
.map_err(|e| format!("bad image data: {e}"))?,
|
||||
mime: v["mimeType"].as_str().unwrap_or("image/png").to_string(),
|
||||
},
|
||||
_ => Out::Text(v.to_string()),
|
||||
});
|
||||
}
|
||||
Ok((outs, r.is_error != Some(true)))
|
||||
}
|
||||
|
||||
fn runtime() -> Result<tokio::runtime::Runtime, String> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn connect(rt: &tokio::runtime::Runtime) -> Result<Client, String> {
|
||||
let url = mcp::url(mcp::port());
|
||||
rt.block_on(async { ().serve(StreamableHttpClientTransport::from_uri(url.clone())).await })
|
||||
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))
|
||||
}
|
||||
|
||||
fn app_reachable() -> bool {
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, mcp::port()));
|
||||
TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).is_ok()
|
||||
}
|
||||
|
||||
/// The app's `list_resources` result: `Ok(None)` when it isn't running, `Err` when it runs but
|
||||
/// does not answer usably (the caller must refuse then, not guess).
|
||||
fn app_items() -> Result<Option<Value>, String> {
|
||||
if !app_reachable() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rt = runtime()?;
|
||||
let client = connect(&rt)?;
|
||||
let (outs, _) = rt.block_on(online_call(&client, "list_resources", &json!({"limit": LIST_ALL})))?;
|
||||
match outs.first() {
|
||||
Some(Out::Text(t)) => serde_json::from_str(t)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("unreadable list_resources answer: {e}")),
|
||||
_ => Err("empty list_resources answer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Large enough to list every project in one answer.
|
||||
const LIST_ALL: u32 = 100_000;
|
||||
|
||||
/// Refuse to open `target` here while the app has it open, or when that cannot be told.
|
||||
fn check_app_not_using(target: Option<&str>) -> Result<(), String> {
|
||||
match app_items() {
|
||||
Ok(Some(items)) => cli::refuse_if_open(&items, target),
|
||||
Ok(None) => Ok(()),
|
||||
Err(e) => Err(format!("cannot tell whether PaperCraft has the project open ({e}); close the app or retry")),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_offline(project: Option<&str>) -> Result<Backend, String> {
|
||||
let root = Store::default_root();
|
||||
let store = Store::open(&root).map_err(|e| format!("{}: {e}", root.display()))?;
|
||||
let mut o = Offline::new(store, project)?;
|
||||
o.open_guard = Some(Box::new(|id| check_app_not_using(Some(id))));
|
||||
Ok(Backend::Offline(Box::new(o)))
|
||||
}
|
||||
|
||||
/// `check_default`: also refuse when no `--project` is given and the app has the default
|
||||
/// project open (false for a lone create_file, which touches no existing project).
|
||||
fn open_backend(
|
||||
offline: bool,
|
||||
project: Option<&str>,
|
||||
check_default: bool,
|
||||
) -> Result<Backend, String> {
|
||||
if offline {
|
||||
if project.is_some() || check_default {
|
||||
check_app_not_using(project)?;
|
||||
}
|
||||
return open_offline(project);
|
||||
}
|
||||
if app_reachable() {
|
||||
let rt = runtime()?;
|
||||
let client = connect(&rt)?;
|
||||
if let Some(p) = project {
|
||||
rt.block_on(online_call(&client, "open_file", &json!({"fileId": p})))?;
|
||||
}
|
||||
return Ok(Backend::Online(rt, client));
|
||||
}
|
||||
eprintln!("papercraft-cli: app not running; working offline");
|
||||
open_offline(project)
|
||||
}
|
||||
|
||||
/// Print a tool's output: text to stdout, images to a file whose path is printed. Returns the
|
||||
/// saved image bytes (for inline display).
|
||||
fn emit(outs: &[Out], name: &str, out: Option<&str>) -> Result<Vec<Vec<u8>>, String> {
|
||||
let mut images = Vec::new();
|
||||
for o in outs {
|
||||
match o {
|
||||
Out::Text(t) => println!("{t}"),
|
||||
Out::Image { data, .. } => {
|
||||
let path = out.map_or_else(|| format!("{name}.png"), String::from);
|
||||
save(&path, data)?;
|
||||
println!("{path}");
|
||||
images.push(data.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
fn save(path: &str, data: &[u8]) -> Result<(), String> {
|
||||
if let Some(p) = std::path::Path::new(path)
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
{
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
std::fs::write(path, data).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn out_arg(args: &Value, out: Option<String>) -> Option<String> {
|
||||
out.or_else(|| {
|
||||
args.get("out")
|
||||
.or_else(|| args.get("path"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
});
|
||||
let url = mcp::url(mcp::port());
|
||||
let client = ()
|
||||
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))?;
|
||||
let mut p = CallToolRequestParams::new(name.clone());
|
||||
match args {
|
||||
Value::Object(m) => p.arguments = Some(m),
|
||||
Value::Null => {}
|
||||
_ => return Err("arguments must be a JSON object".into()),
|
||||
}
|
||||
let r = client.call_tool(p).await.map_err(|e| e.to_string())?;
|
||||
// Blocks are read through their JSON form, so this doesn't depend on rmcp's enum layout.
|
||||
for block in r.content {
|
||||
let v = serde_json::to_value(&block).map_err(|e| e.to_string())?;
|
||||
match v.get("type").and_then(Value::as_str) {
|
||||
Some("text") => println!("{}", v["text"].as_str().unwrap_or("")),
|
||||
Some("image") => {
|
||||
let data = base64::engine::general_purpose::STANDARD
|
||||
.decode(v["data"].as_str().unwrap_or(""))
|
||||
.map_err(|e| format!("bad image data: {e}"))?;
|
||||
let path = out.clone().unwrap_or_else(|| format!("{name}.png"));
|
||||
if let Some(p) = std::path::Path::new(&path)
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
{
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
std::fs::write(&path, data).map_err(|e| format!("{path}: {e}"))?;
|
||||
println!("{path}");
|
||||
}
|
||||
_ => println!("{v}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn call_once(
|
||||
name: String,
|
||||
args: Value,
|
||||
out: Option<String>,
|
||||
offline: bool,
|
||||
project: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let out = out_arg(&args, out);
|
||||
let mut be = open_backend(
|
||||
offline,
|
||||
project.as_deref(),
|
||||
name != "create_file" && name != "open_file",
|
||||
)?;
|
||||
let (outs, ok) = be.call(&name, &args)?;
|
||||
emit(&outs, &name, out.as_deref())?;
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------- shell
|
||||
|
||||
const SHELL_HELP: &str = "\
|
||||
<tool> <json> run a tool (JSON may span lines until its braces close)
|
||||
:tools tool names :schema <tool> one tool's schema
|
||||
:open <project> open a project :pages pages (get_basic_info)
|
||||
:page <name|id> switch page (offline only)
|
||||
:shot [nodeId] [file] screenshot to file (default shot.png)
|
||||
:undo :redo :save :watch on|off :quit";
|
||||
|
||||
const WATCH_FILE: &str = "preview.png";
|
||||
|
||||
struct Shell {
|
||||
be: Backend,
|
||||
offline: bool,
|
||||
watch: bool,
|
||||
last: Option<String>,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
fn show(&self, png: &[u8]) {
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return;
|
||||
}
|
||||
let tp = std::env::var("TERM_PROGRAM").unwrap_or_default();
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
if let Some(seq) = cli::inline_image(&tp, &term, png) {
|
||||
print!("{seq}");
|
||||
}
|
||||
}
|
||||
Ok(r.is_error != Some(true))
|
||||
|
||||
fn call(&mut self, name: &str, args: &Value, out: Option<&str>) -> bool {
|
||||
let (outs, ok) = match self.be.call(name, args) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("papercraft-cli: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let images = match emit(&outs, name, out) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("papercraft-cli: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for png in &images {
|
||||
self.show(png);
|
||||
}
|
||||
if ok && tools::edits_doc(name, args) {
|
||||
self.last = match &self.be {
|
||||
Backend::Offline(o) => o.last_touched.clone(),
|
||||
Backend::Online(..) => cli::touched_key(args, &outs),
|
||||
}
|
||||
.or(self.last.take());
|
||||
if self.watch {
|
||||
self.preview();
|
||||
}
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn preview(&mut self) {
|
||||
let args = match &self.last {
|
||||
Some(k) => json!({"nodeId": k}),
|
||||
None => Value::Null,
|
||||
};
|
||||
match self.be.call("get_screenshot", &args) {
|
||||
Ok((outs, true)) => {
|
||||
for o in outs {
|
||||
if let Out::Image { data, .. } = o {
|
||||
match save(WATCH_FILE, &data) {
|
||||
Ok(()) => {
|
||||
println!("{WATCH_FILE}");
|
||||
self.show(&data);
|
||||
}
|
||||
Err(e) => eprintln!("papercraft-cli: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((outs, false)) => {
|
||||
for o in outs {
|
||||
if let Out::Text(t) = o {
|
||||
eprintln!("papercraft-cli: watch: {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("papercraft-cli: watch: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one statement. `None` means quit.
|
||||
fn run(&mut self, stmt: Stmt) -> Option<bool> {
|
||||
let (name, a) = match stmt {
|
||||
Stmt::Call { tool, args } => {
|
||||
let out = out_arg(&args, None);
|
||||
return Some(self.call(&tool, &args, out.as_deref()));
|
||||
}
|
||||
Stmt::Cmd { name, args } => (name, args),
|
||||
};
|
||||
let arg = a.first().map(String::as_str);
|
||||
Some(match (name.as_str(), arg) {
|
||||
("quit" | "q" | "exit", _) => return None,
|
||||
("help" | "h", _) => {
|
||||
println!("{SHELL_HELP}");
|
||||
true
|
||||
}
|
||||
("tools", _) => {
|
||||
list();
|
||||
true
|
||||
}
|
||||
("schema", Some(t)) => schema(t),
|
||||
("open", Some(p)) => {
|
||||
if self.offline
|
||||
&& let Err(e) = check_app_not_using(Some(p))
|
||||
{
|
||||
eprintln!("papercraft-cli: {e}");
|
||||
return Some(false);
|
||||
}
|
||||
self.call("open_file", &json!({"fileId": p}), None)
|
||||
}
|
||||
("pages", _) => self.call("get_basic_info", &Value::Null, None),
|
||||
("page", Some(p)) => match &mut self.be {
|
||||
Backend::Offline(o) => match o.switch_page(p) {
|
||||
Ok(n) => {
|
||||
println!("page {n}");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("papercraft-cli: {e}");
|
||||
false
|
||||
}
|
||||
},
|
||||
Backend::Online(..) => {
|
||||
eprintln!("papercraft-cli: :page works offline only; switch pages in the app");
|
||||
false
|
||||
}
|
||||
},
|
||||
("shot", _) => {
|
||||
// A node id has a colon or is a number; anything else is the file.
|
||||
let is_node = |s: &str| s.contains(':') || s.chars().all(|c| c.is_ascii_digit());
|
||||
let (node, file) = match (a.first(), a.get(1)) {
|
||||
(Some(n), f) if is_node(n) => (Some(n.clone()), f.cloned()),
|
||||
(f, _) => (None, f.cloned()),
|
||||
};
|
||||
let args = node.map_or(Value::Null, |n| json!({"nodeId": n}));
|
||||
let file = file.unwrap_or_else(|| "shot.png".into());
|
||||
self.call("get_screenshot", &args, Some(&file))
|
||||
}
|
||||
("undo", _) => self.call("undo", &Value::Null, None),
|
||||
("redo", _) => self.call("redo", &Value::Null, None),
|
||||
("save", _) => self.call("finish_working_on_nodes", &Value::Null, None),
|
||||
("watch", Some("on")) => {
|
||||
self.watch = true;
|
||||
true
|
||||
}
|
||||
("watch", Some("off")) => {
|
||||
self.watch = false;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
eprintln!("papercraft-cli: unknown or incomplete command :{name} (try :help)");
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn shell(offline: bool, project: Option<String>) -> ExitCode {
|
||||
let be = match open_backend(offline, project.as_deref(), true) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let mut sh = Shell {
|
||||
be,
|
||||
offline,
|
||||
watch: false,
|
||||
last: None,
|
||||
};
|
||||
let tty = std::io::stdin().is_terminal();
|
||||
let mut acc = Accum::default();
|
||||
let mut failed: Vec<usize> = Vec::new();
|
||||
let mut n = 0;
|
||||
let mut quit = false;
|
||||
let mut run = |sh: &mut Shell, at: usize, s: String| {
|
||||
match cli::parse_statement(&s) {
|
||||
Ok(stmt) => match sh.run(stmt) {
|
||||
None => return true,
|
||||
Some(false) => failed.push(at),
|
||||
Some(true) => {}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("papercraft-cli: line {at}: {e}");
|
||||
failed.push(at);
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
let prompt = |acc: &Accum| {
|
||||
if tty {
|
||||
print!(
|
||||
"{}",
|
||||
if acc.pending() {
|
||||
"...> "
|
||||
} else {
|
||||
"papercraft> "
|
||||
}
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
};
|
||||
prompt(&acc);
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
n += 1;
|
||||
if let Some((at, s)) = acc.push(n, &line)
|
||||
&& run(&mut sh, at, s)
|
||||
{
|
||||
quit = true;
|
||||
break;
|
||||
}
|
||||
prompt(&acc);
|
||||
}
|
||||
if !quit && let Some((at, s)) = acc.finish() {
|
||||
run(&mut sh, at, s);
|
||||
}
|
||||
if failed.is_empty() || tty {
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
eprintln!(
|
||||
"papercraft-cli: failed lines: {}",
|
||||
failed
|
||||
.iter()
|
||||
.map(usize::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
fn take_flag(args: &mut Vec<String>, long: &str, short: Option<&str>) -> Option<String> {
|
||||
let eq = format!("{long}=");
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if (args[i] == long || Some(args[i].as_str()) == short) && i + 1 < args.len() {
|
||||
return Some(args.drain(i..=i + 1).nth(1).unwrap());
|
||||
}
|
||||
if let Some(v) = args[i].strip_prefix(&eq) {
|
||||
let v = v.to_string();
|
||||
args.remove(i);
|
||||
return Some(v);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut out: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if (args[i] == "--out" || args[i] == "-o") && i + 1 < args.len() {
|
||||
out = Some(args.drain(i..=i + 1).nth(1).unwrap());
|
||||
} else if let Some(v) = args[i].strip_prefix("--out=") {
|
||||
let v = v.to_string();
|
||||
args.remove(i);
|
||||
out = Some(v);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let out = take_flag(&mut args, "--out", Some("-o"));
|
||||
let project = take_flag(&mut args, "--project", None);
|
||||
let offline = args.iter().any(|a| a == "--offline");
|
||||
args.retain(|a| a != "--offline");
|
||||
match args.first().map(String::as_str) {
|
||||
Some("list") => list(),
|
||||
Some("schema") => match args.get(1) {
|
||||
Some(n) => return schema(n),
|
||||
Some(n) => {
|
||||
return if schema(n) {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
};
|
||||
}
|
||||
None => return fail("schema needs a tool name"),
|
||||
},
|
||||
Some("icons") => return icons_cmd(&args[1..]),
|
||||
Some("--url") => println!("{}", mcp::url(mcp::port())),
|
||||
Some("shell") => return shell(offline, project),
|
||||
Some("call") => {
|
||||
let Some(name) = args.get(1).cloned() else {
|
||||
return fail("call needs a tool name");
|
||||
@@ -287,14 +710,7 @@ fn main() -> ExitCode {
|
||||
Err(e) => return fail(format!("arguments are not valid JSON: {e}")),
|
||||
}
|
||||
};
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
return match rt.block_on(call(name, value, out)) {
|
||||
return match call_once(name, value, out, offline, project) {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::FAILURE,
|
||||
Err(e) => fail(e),
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
//! A generic Agent Client Protocol client (https://agentclientprotocol.com): JSON-RPC 2.0, one
|
||||
//! message per line, over the agent's stdio. One process per turn: `initialize`, `session/new`
|
||||
//! (or `session/load`) with PaperCraft's MCP server, `session/prompt`, then the agent's
|
||||
//! `session/update` notifications are mapped to [`Event`]s until the prompt response arrives.
|
||||
|
||||
use super::events::{Event, Turn, TurnConfig};
|
||||
use super::{tools, Usage};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PROTOCOL_VERSION: u64 = 1;
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const POLL: Duration = Duration::from_millis(20);
|
||||
const TICK: Duration = Duration::from_millis(50);
|
||||
/// Agents may start slowly (node, login); a prompt itself has no limit.
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// After the turn the agent gets this long to exit on its own (flush its session), then dies.
|
||||
const EXIT_GRACE: Duration = Duration::from_secs(1);
|
||||
const STDERR_MAX: usize = 16 * 1024;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
/// The Plan card is the existing `TodoWrite` card.
|
||||
const PLAN_TOOL: &str = "TodoWrite";
|
||||
const SERVER_NAME: &str = "papercraft";
|
||||
const BRIDGE: &str = "papercraft-mcp";
|
||||
/// How agents prefix MCP tool names; longest first.
|
||||
const NAME_PREFIXES: [&str; 4] = ["mcp__papercraft__", "mcp_papercraft_", "papercraft__", "papercraft_"];
|
||||
/// Same keys the other backends drop: the agent must run on its own login.
|
||||
const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
|
||||
type Stdin = Arc<Mutex<Option<ChildStdin>>>;
|
||||
|
||||
fn send(stdin: &Stdin, v: &Value) -> bool {
|
||||
let mut g = stdin.lock().unwrap();
|
||||
let Some(w) = g.as_mut() else { return false };
|
||||
writeln!(w, "{v}").and_then(|_| w.flush()).is_ok()
|
||||
}
|
||||
|
||||
/// SIGKILL to the agent's whole process group (it is its own group), so its helpers die too.
|
||||
fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
pub struct AcpTurn {
|
||||
child: Arc<Mutex<Child>>,
|
||||
stdin: Stdin,
|
||||
session: Arc<Mutex<Option<String>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
approvals: Sender<(String, bool)>,
|
||||
}
|
||||
|
||||
impl AcpTurn {
|
||||
/// Answers a `NeedsApproval` event (`id` = the event's id).
|
||||
pub fn approve(&self, id: &str, ok: bool) {
|
||||
let _ = self.approvals.send((id.to_string(), ok));
|
||||
}
|
||||
}
|
||||
|
||||
impl Turn for AcpTurn {
|
||||
/// Never blocks: the cancel write and the kill timer run on a thread.
|
||||
fn stop(&mut self) {
|
||||
if self.stopped.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let (child, stdin, session) = (self.child.clone(), self.stdin.clone(), self.session.clone());
|
||||
// The kill timer starts first: a stalled agent can block the cancel write forever.
|
||||
let deadline = Instant::now() + STOP_TIMEOUT;
|
||||
thread::spawn(move || loop {
|
||||
let mut c = child.lock().unwrap();
|
||||
if !matches!(c.try_wait(), Ok(None)) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill_tree(&mut c);
|
||||
return;
|
||||
}
|
||||
drop(c);
|
||||
thread::sleep(POLL);
|
||||
});
|
||||
thread::spawn(move || {
|
||||
let sid = session.lock().unwrap().clone();
|
||||
if let Some(sid) = sid {
|
||||
send(&stdin, &json!({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": sid}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `program args…` and runs one prompt. Events arrive on `tx`; the last is `TurnEnd` or
|
||||
/// `Error`.
|
||||
pub fn start(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Sender<Event>) -> Result<AcpTurn, String> {
|
||||
spawn(cfg, program, args, prompt, tx, None)
|
||||
}
|
||||
|
||||
fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Sender<Event>, bridge: Option<PathBuf>) -> Result<AcpTurn, String> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args).current_dir(&cfg.project_dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
for k in CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
super::path::apply(&mut cmd);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
|
||||
let mut child = cmd.spawn().map_err(|e| format!("Could not start {}: {e}", program.display()))?;
|
||||
let (stdout, mut stderr) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
|
||||
let stdin: Stdin = Arc::new(Mutex::new(child.stdin.take()));
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
let (approvals, approvals_rx) = channel();
|
||||
let turn = AcpTurn {
|
||||
child: child.clone(),
|
||||
stdin: stdin.clone(),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
running: Arc::new(AtomicBool::new(true)),
|
||||
stopped: Arc::new(AtomicBool::new(false)),
|
||||
approvals,
|
||||
};
|
||||
let (session, running, stopped) = (turn.session.clone(), turn.running.clone(), turn.stopped.clone());
|
||||
|
||||
let (line_tx, rx) = channel();
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(v) if v.is_object() => {
|
||||
if line_tx.send(v).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => log::debug!("acp: skipped non-JSON line: {line}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
let err_text = Arc::new(Mutex::new(String::new()));
|
||||
let err_sink = err_text.clone();
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 1024];
|
||||
while let Ok(n @ 1..) = stderr.read(&mut buf) {
|
||||
let mut t = err_sink.lock().unwrap();
|
||||
if t.len() < STDERR_MAX {
|
||||
t.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (cfg, prompt, name) = (cfg.clone(), prompt.to_string(), program.display().to_string());
|
||||
thread::spawn(move || {
|
||||
let mut c = Client {
|
||||
rx,
|
||||
stdin: stdin.clone(),
|
||||
approvals: approvals_rx,
|
||||
stopped: stopped.clone(),
|
||||
tx: tx.clone(),
|
||||
next_id: 0,
|
||||
replay: false,
|
||||
pending: HashMap::new(),
|
||||
tools: HashMap::new(),
|
||||
usage: None,
|
||||
cost: None,
|
||||
plans: 0,
|
||||
sid: None,
|
||||
};
|
||||
let result = c.run(&cfg, &prompt, bridge, &session);
|
||||
c.close_open_tools();
|
||||
let end = match result {
|
||||
Ok(stop) => Event::TurnEnd {
|
||||
usage: c.usage.take(),
|
||||
session: c.sid.clone(),
|
||||
stop_reason: Some(if stop == "cancelled" { "stopped".into() } else { stop }),
|
||||
},
|
||||
Err(_) if stopped.load(Ordering::SeqCst) => Event::TurnEnd { usage: None, session: c.sid.clone(), stop_reason: Some("stopped".into()) },
|
||||
Err(e) => {
|
||||
// Give a dying agent a moment to flush its error text.
|
||||
thread::sleep(POLL * 5);
|
||||
let err = err_text.lock().unwrap().trim().to_string();
|
||||
Event::Error(if err.is_empty() { format!("{name}: {e}") } else { format!("{name}: {e}: {}", tail(&err)) })
|
||||
}
|
||||
};
|
||||
// Send first: the app treats "not running" as "everything is in the channel".
|
||||
let _ = tx.send(end);
|
||||
stdin.lock().unwrap().take(); // EOF: a well-behaved agent exits
|
||||
let deadline = Instant::now() + EXIT_GRACE;
|
||||
loop {
|
||||
let mut ch = child.lock().unwrap();
|
||||
if !matches!(ch.try_wait(), Ok(None)) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill_tree(&mut ch);
|
||||
break;
|
||||
}
|
||||
drop(ch);
|
||||
thread::sleep(POLL);
|
||||
}
|
||||
running.store(false, Ordering::Release);
|
||||
});
|
||||
Ok(turn)
|
||||
}
|
||||
|
||||
fn tail(s: &str) -> &str {
|
||||
const KEEP: usize = 600;
|
||||
let mut from = s.len().saturating_sub(KEEP);
|
||||
while !s.is_char_boundary(from) {
|
||||
from += 1;
|
||||
}
|
||||
&s[from..]
|
||||
}
|
||||
|
||||
struct Tool {
|
||||
name: String,
|
||||
args: Value,
|
||||
ended: bool,
|
||||
/// Announced as one of PaperCraft's own MCP tools (never a shell, edit or fetch).
|
||||
ours: bool,
|
||||
}
|
||||
|
||||
struct Client {
|
||||
rx: Receiver<Value>,
|
||||
stdin: Stdin,
|
||||
approvals: Receiver<(String, bool)>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
tx: Sender<Event>,
|
||||
next_id: u64,
|
||||
/// `session/load` replays the history as updates; those are not new output.
|
||||
replay: bool,
|
||||
/// Permission requests waiting for the user: tool call id -> (JSON-RPC id, options).
|
||||
pending: HashMap<String, (Value, Vec<Value>)>,
|
||||
tools: HashMap<String, Tool>,
|
||||
usage: Option<Usage>,
|
||||
cost: Option<f64>,
|
||||
plans: u32,
|
||||
sid: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn emit(&self, e: Event) {
|
||||
let _ = self.tx.send(e);
|
||||
}
|
||||
|
||||
fn run(&mut self, cfg: &TurnConfig, prompt: &str, bridge: Option<PathBuf>, slot: &Mutex<Option<String>>) -> Result<String, String> {
|
||||
let init = self.call(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false},
|
||||
"clientInfo": {"name": "papercraft", "title": "PaperCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
}),
|
||||
Some(HANDSHAKE_TIMEOUT),
|
||||
)?;
|
||||
let caps = &init["agentCapabilities"];
|
||||
let http = caps["mcpCapabilities"]["http"] == true;
|
||||
let image = caps["promptCapabilities"]["image"] == true;
|
||||
let bridge = if http { None } else { bridge.or_else(find_bridge) };
|
||||
let session_params = |extra: Value| -> Result<Value, String> {
|
||||
let mut p = json!({"cwd": cfg.project_dir, "mcpServers": mcp_servers(&cfg.mcp_url, http, bridge.as_deref())?});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
Ok(p)
|
||||
};
|
||||
|
||||
let mut loaded = None;
|
||||
if let Some(id) = cfg.session.as_ref().filter(|_| caps["loadSession"] == true) {
|
||||
self.replay = true;
|
||||
let r = self.call("session/load", session_params(json!({"sessionId": id}))?, Some(HANDSHAKE_TIMEOUT));
|
||||
self.replay = false;
|
||||
match r {
|
||||
Ok(_) => loaded = Some(id.clone()),
|
||||
Err(e) => log::warn!("acp: session/load failed ({e}); starting a new session"),
|
||||
}
|
||||
}
|
||||
let first = loaded.is_none();
|
||||
let sid = match loaded {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let r = self.call("session/new", session_params(json!({}))?, Some(HANDSHAKE_TIMEOUT))?;
|
||||
r["sessionId"].as_str().ok_or("session/new returned no sessionId")?.to_string()
|
||||
}
|
||||
};
|
||||
self.sid = Some(sid.clone());
|
||||
*slot.lock().unwrap() = Some(sid.clone());
|
||||
self.emit(Event::Session(sid.clone()));
|
||||
|
||||
// ACP has no system prompt: the PaperCraft text leads the first prompt of a session.
|
||||
let mut blocks = vec![];
|
||||
if first && !cfg.system.is_empty() {
|
||||
blocks.push(json!({"type": "text", "text": cfg.system}));
|
||||
}
|
||||
blocks.push(json!({"type": "text", "text": prompt}));
|
||||
if image {
|
||||
blocks.extend(cfg.images.iter().map(|i| json!({"type": "image", "mimeType": i.mime, "data": STANDARD.encode(&i.data)})));
|
||||
}
|
||||
let r = self.call("session/prompt", json!({"sessionId": sid, "prompt": blocks}), None)?;
|
||||
self.usage = usage_of(&r["usage"]).map(|mut u| {
|
||||
u.cost_usd = self.cost;
|
||||
u
|
||||
});
|
||||
if self.usage.is_none() && self.cost.is_some() {
|
||||
self.usage = Some(Usage { cost_usd: self.cost, ..Default::default() });
|
||||
}
|
||||
Ok(r["stopReason"].as_str().unwrap_or("end_turn").to_string())
|
||||
}
|
||||
|
||||
/// Sends a request and serves the agent's messages until its response arrives.
|
||||
fn call(&mut self, method: &str, params: Value, timeout: Option<Duration>) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
if !send(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})) {
|
||||
return Err("the agent closed its input".into());
|
||||
}
|
||||
let deadline = timeout.map(|t| Instant::now() + t);
|
||||
loop {
|
||||
while let Ok((tool, ok)) = self.approvals.try_recv() {
|
||||
if let Some((rpc, options)) = self.pending.remove(&tool) {
|
||||
self.answer(&rpc, &options, ok);
|
||||
}
|
||||
}
|
||||
if self.stopped.load(Ordering::SeqCst) {
|
||||
// The spec: every pending permission request gets the `cancelled` outcome.
|
||||
for (_, (rpc, _)) in self.pending.drain() {
|
||||
send(&self.stdin, &reply(&rpc, json!({"outcome": {"outcome": "cancelled"}})));
|
||||
}
|
||||
}
|
||||
match self.rx.recv_timeout(TICK) {
|
||||
Ok(msg) if msg.get("method").is_some() => self.incoming(&msg),
|
||||
Ok(msg) if msg["id"] == json!(id) => {
|
||||
return match msg.get("error") {
|
||||
Some(e) => Err(rpc_error(e)),
|
||||
None => Ok(msg["result"].clone()),
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
return Err(format!("{method} timed out"));
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Err("the agent exited".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn incoming(&mut self, msg: &Value) {
|
||||
let method = msg["method"].as_str().unwrap_or_default();
|
||||
match (method, msg.get("id")) {
|
||||
("session/update", _) => self.update(&msg["params"]["update"]),
|
||||
("session/request_permission", Some(id)) => self.permission(id.clone(), &msg["params"]),
|
||||
(m, Some(id)) => {
|
||||
log::debug!("acp: unsupported client method {m}");
|
||||
send(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "error": {"code": METHOD_NOT_FOUND, "message": format!("method not found: {m}")}}));
|
||||
}
|
||||
(m, None) => log::debug!("acp: ignored notification {m}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, u: &Value) {
|
||||
if self.replay {
|
||||
return;
|
||||
}
|
||||
let text = |v: &Value| v["content"]["text"].as_str().filter(|t| !t.is_empty()).map(String::from);
|
||||
match u["sessionUpdate"].as_str().unwrap_or_default() {
|
||||
"agent_message_chunk" => text(u).into_iter().for_each(|t| self.emit(Event::Text(t))),
|
||||
"agent_thought_chunk" => text(u).into_iter().for_each(|t| self.emit(Event::Thinking(t))),
|
||||
"tool_call" => self.tool_call(u),
|
||||
"tool_call_update" => {
|
||||
if !self.tools.contains_key(&call_id(u)) {
|
||||
self.tool_call(u);
|
||||
} else {
|
||||
self.tool_status(u);
|
||||
}
|
||||
}
|
||||
"plan" => self.plan(u),
|
||||
"usage_update" => {
|
||||
if let Some(used) = u["used"].as_u64() {
|
||||
self.emit(Event::Context(used));
|
||||
}
|
||||
if u["cost"]["currency"] == "USD" {
|
||||
self.cost = u["cost"]["amount"].as_f64();
|
||||
}
|
||||
}
|
||||
other => log::debug!("acp: skipped session update {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_call(&mut self, u: &Value) {
|
||||
let id = call_id(u);
|
||||
if id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let args = if u["rawInput"].is_null() { json!({}) } else { u["rawInput"].clone() };
|
||||
let name = canonical_name(&tool_name(u));
|
||||
let ours = is_ours(&name, u["kind"].as_str());
|
||||
self.tools.insert(id.clone(), Tool { name: name.clone(), args: args.clone(), ended: false, ours });
|
||||
self.emit(Event::ToolStart { id, name, args });
|
||||
self.tool_status(u);
|
||||
}
|
||||
|
||||
fn tool_status(&mut self, u: &Value) {
|
||||
let id = call_id(u);
|
||||
let Some(t) = self.tools.get_mut(&id) else { return };
|
||||
if t.ended || !["completed", "failed"].contains(&u["status"].as_str().unwrap_or_default()) {
|
||||
return;
|
||||
}
|
||||
t.ended = true;
|
||||
let text = content_text(&u["content"]);
|
||||
let ev = if u["status"] == "failed" {
|
||||
let msg = if !text.is_empty() { text } else if u["rawOutput"].is_null() { "tool failed".into() } else { u["rawOutput"].to_string() };
|
||||
Event::ToolEnd { id, result: None, error: Some(msg) }
|
||||
} else {
|
||||
let result = if !text.is_empty() { json!(text) } else if u["rawOutput"].is_null() { json!("") } else { u["rawOutput"].clone() };
|
||||
Event::ToolEnd { id, result: Some(result), error: None }
|
||||
};
|
||||
self.emit(ev);
|
||||
}
|
||||
|
||||
/// A stopped turn leaves tools open; close them so no card spins forever.
|
||||
fn close_open_tools(&mut self) {
|
||||
let open: Vec<String> = self.tools.iter().filter(|(_, t)| !t.ended).map(|(id, _)| id.clone()).collect();
|
||||
for id in open {
|
||||
if let Some(t) = self.tools.get_mut(&id) {
|
||||
t.ended = true;
|
||||
}
|
||||
self.emit(Event::ToolEnd { id, result: None, error: Some(tools::STOPPED_BY_USER.into()) });
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(&mut self, u: &Value) {
|
||||
let Some(entries) = u["entries"].as_array() else { return };
|
||||
let todos: Vec<Value> = entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let status = match e["status"].as_str() {
|
||||
Some(s @ ("in_progress" | "completed")) => s,
|
||||
_ => "pending",
|
||||
};
|
||||
Some(json!({"content": e["content"].as_str()?, "status": status}))
|
||||
})
|
||||
.collect();
|
||||
self.plans += 1;
|
||||
let id = format!("acp-plan-{}", self.plans);
|
||||
self.emit(Event::ToolStart { id: id.clone(), name: PLAN_TOOL.into(), args: json!({"todos": todos}) });
|
||||
self.emit(Event::ToolEnd { id, result: Some(json!("ok")), error: None });
|
||||
}
|
||||
|
||||
fn permission(&mut self, rpc: Value, params: &Value) {
|
||||
let options = params["options"].as_array().cloned().unwrap_or_default();
|
||||
let tc = ¶ms["toolCall"];
|
||||
let id = call_id(tc);
|
||||
let known = self.tools.get(&id);
|
||||
let name = known.map_or_else(|| canonical_name(&tool_name(tc)), |t| t.name.clone());
|
||||
let args = if !tc["rawInput"].is_null() { tc["rawInput"].clone() } else { known.map_or(json!({}), |t| t.args.clone()) };
|
||||
if known.is_some_and(|t| t.ours) && is_ours(&name, tc["kind"].as_str()) && !tools::needs_approval(&name, &args) {
|
||||
return self.answer(&rpc, &options, true);
|
||||
}
|
||||
self.pending.insert(id.clone(), (rpc, options));
|
||||
self.emit(Event::NeedsApproval { id, name, args });
|
||||
}
|
||||
|
||||
fn answer(&self, rpc: &Value, options: &[Value], allow: bool) {
|
||||
let prefix = if allow { "allow" } else { "reject" };
|
||||
let pick = ["_once", "_always"]
|
||||
.iter()
|
||||
.find_map(|s| options.iter().find(|o| o["kind"] == format!("{prefix}{s}")))
|
||||
.and_then(|o| o["optionId"].as_str());
|
||||
let outcome = match pick {
|
||||
Some(id) => json!({"outcome": "selected", "optionId": id}),
|
||||
None => json!({"outcome": "cancelled"}),
|
||||
};
|
||||
send(&self.stdin, &reply(rpc, json!({"outcome": outcome})));
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(id: &Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
|
||||
fn rpc_error(e: &Value) -> String {
|
||||
let mut m = e["message"].as_str().unwrap_or("request failed").to_string();
|
||||
if let Some(d) = e["data"].as_str().or_else(|| e["data"]["details"].as_str()) {
|
||||
m = format!("{m} ({d})");
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn call_id(u: &Value) -> String {
|
||||
u["toolCallId"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// `name` when the agent sends one, else the human `title`.
|
||||
fn tool_name(u: &Value) -> String {
|
||||
u["name"].as_str().or_else(|| u["title"].as_str()).unwrap_or("tool").to_string()
|
||||
}
|
||||
|
||||
/// Name without the agent's MCP prefix or a trailing ` (papercraft MCP Server)` suffix.
|
||||
fn canonical_name(name: &str) -> String {
|
||||
let name = name.trim();
|
||||
let name = match name.rfind(" (") {
|
||||
Some(i) if name.ends_with(')') && name.to_ascii_lowercase()[i..].contains("mcp server") => &name[..i],
|
||||
_ => name,
|
||||
};
|
||||
NAME_PREFIXES.iter().find_map(|p| name.strip_prefix(p)).unwrap_or(name).trim().to_string()
|
||||
}
|
||||
|
||||
/// A tool of PaperCraft's own MCP server: the canonical name is one of our tools and the kind is
|
||||
/// not a built-in agent kind (execute, edit, fetch, delete...).
|
||||
fn is_ours(canonical: &str, kind: Option<&str>) -> bool {
|
||||
matches!(kind, None | Some("other")) && tools::tool_specs().iter().any(|t| t["name"] == canonical)
|
||||
}
|
||||
|
||||
/// Text of a `ToolCallContent` array: `{type:"content", content:{type:"text", text}}` entries.
|
||||
fn content_text(v: &Value) -> String {
|
||||
v.as_array()
|
||||
.map(|a| a.iter().filter_map(|c| c["content"]["text"].as_str()).collect::<Vec<_>>().join("\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `{inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens}` of a prompt response.
|
||||
fn usage_of(u: &Value) -> Option<Usage> {
|
||||
if !u.is_object() {
|
||||
return None;
|
||||
}
|
||||
let get = |k: &str| u[k].as_u64().unwrap_or(0);
|
||||
Some(Usage { input: get("inputTokens"), output: get("outputTokens"), cache_read: get("cachedReadTokens"), cache_write: get("cachedWriteTokens"), cost_usd: None })
|
||||
}
|
||||
|
||||
/// PaperCraft's MCP server as an ACP `mcpServers` list: HTTP when the agent supports it, else
|
||||
/// the stdio `papercraft-mcp` bridge aimed at the same port.
|
||||
fn mcp_servers(url: &str, http: bool, bridge: Option<&Path>) -> Result<Value, String> {
|
||||
if http {
|
||||
return Ok(json!([{"type": "http", "name": SERVER_NAME, "url": url, "headers": []}]));
|
||||
}
|
||||
let bridge = bridge.ok_or("This agent cannot use HTTP MCP servers and the stdio bridge (papercraft-mcp) was not found")?;
|
||||
let port = url.rsplit_once(':').map(|(_, r)| r.split('/').next().unwrap_or_default()).filter(|p| p.parse::<u16>().is_ok());
|
||||
let env: Vec<Value> = port.into_iter().map(|p| json!({"name": "PAPERCRAFT_MCP_PORT", "value": p})).collect();
|
||||
Ok(json!([{"name": SERVER_NAME, "command": bridge, "args": [], "env": env}]))
|
||||
}
|
||||
|
||||
/// `papercraft-mcp` next to the running executable, else on PATH.
|
||||
fn find_bridge() -> Option<PathBuf> {
|
||||
let file = format!("{BRIDGE}{}", std::env::consts::EXE_SUFFIX);
|
||||
std::env::current_exe().ok().and_then(|e| e.parent().map(|d| d.join(&file))).filter(|p| p.is_file()).or_else(|| super::path::which(BRIDGE))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AGENT: &str = include_str!("../../tests/fixtures/chat/acp_fake_agent.sh");
|
||||
const HTTP: &str = include_str!("../../tests/fixtures/chat/acp_http.jsonl");
|
||||
const STDIO: &str = include_str!("../../tests/fixtures/chat/acp_stdio.jsonl");
|
||||
const PERMISSION: &str = include_str!("../../tests/fixtures/chat/acp_permission.jsonl");
|
||||
const CANCEL: &str = include_str!("../../tests/fixtures/chat/acp_cancel.jsonl");
|
||||
const LOAD: &str = include_str!("../../tests/fixtures/chat/acp_load.jsonl");
|
||||
const LOAD_FAILS: &str = include_str!("../../tests/fixtures/chat/acp_load_fails.jsonl");
|
||||
const ERROR: &str = include_str!("../../tests/fixtures/chat/acp_error.jsonl");
|
||||
|
||||
fn cfg() -> TurnConfig {
|
||||
TurnConfig { project_dir: std::env::temp_dir(), mcp_url: "http://127.0.0.1:29991/mcp".into(), system: "SYS".into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Runs the fake agent on `fixture`; `on` sees every event and may approve or stop. Returns
|
||||
/// the events and the JSON lines the client wrote.
|
||||
fn run(fixture: &str, cfg: TurnConfig, bridge: Option<PathBuf>, mut on: impl FnMut(&mut AcpTurn, &Event)) -> (Vec<Event>, Vec<Value>) {
|
||||
let dir = std::env::temp_dir().join(format!("pc-acp-{}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (script, fx, log) = (dir.join("agent.sh"), dir.join("fixture.jsonl"), dir.join("client.log"));
|
||||
std::fs::write(&script, AGENT).unwrap();
|
||||
std::fs::write(&fx, fixture).unwrap();
|
||||
let args: Vec<String> = [&script, &fx, &log].iter().map(|p| p.display().to_string()).collect();
|
||||
let (tx, rx) = channel();
|
||||
let mut turn = spawn(&cfg, Path::new("/bin/sh"), &args, "hello", tx, bridge).unwrap();
|
||||
let mut events = vec![];
|
||||
loop {
|
||||
let e = rx.recv_timeout(Duration::from_secs(15)).expect("turn timed out");
|
||||
on(&mut turn, &e);
|
||||
let done = matches!(e, Event::TurnEnd { .. } | Event::Error(_));
|
||||
events.push(e);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let t = Instant::now();
|
||||
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
|
||||
thread::sleep(POLL);
|
||||
}
|
||||
assert!(!turn.is_running());
|
||||
let sent = std::fs::read_to_string(&log).unwrap_or_default().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
(events, sent)
|
||||
}
|
||||
|
||||
fn method<'a>(sent: &'a [Value], m: &str) -> &'a Value {
|
||||
sent.iter().find(|v| v["method"] == m).unwrap_or_else(|| panic!("no {m} in {sent:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_handshake_streaming_tools_plan() {
|
||||
let mut c = cfg();
|
||||
c.images = vec![super::super::events::ImageAttachment { mime: "image/png".into(), data: vec![1, 2, 3] }];
|
||||
let (ev, sent) = run(HTTP, c, None, |_, _| {});
|
||||
let init = &method(&sent, "initialize")["params"];
|
||||
assert_eq!(init["protocolVersion"], 1);
|
||||
assert_eq!(init["clientCapabilities"], json!({"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false}));
|
||||
let new = &method(&sent, "session/new")["params"];
|
||||
assert_eq!(new["mcpServers"], json!([{"type": "http", "name": "papercraft", "url": "http://127.0.0.1:29991/mcp", "headers": []}]));
|
||||
assert_eq!(new["cwd"], json!(std::env::temp_dir()));
|
||||
let prompt = &method(&sent, "session/prompt")["params"];
|
||||
assert_eq!(prompt["sessionId"], "sess-1");
|
||||
assert_eq!(
|
||||
prompt["prompt"],
|
||||
json!([{"type": "text", "text": "SYS"}, {"type": "text", "text": "hello"}, {"type": "image", "mimeType": "image/png", "data": "AQID"}])
|
||||
);
|
||||
// unknown client request answered with -32601
|
||||
assert_eq!(sent.iter().find(|v| v["id"] == 50).unwrap()["error"]["code"], METHOD_NOT_FOUND);
|
||||
|
||||
assert_eq!(ev[0], Event::Session("sess-1".into()));
|
||||
assert_eq!(ev[1], Event::Thinking("hmm".into()));
|
||||
assert_eq!(ev[2], Event::Text("Hel".into()));
|
||||
assert_eq!(ev[3], Event::Text("lo".into()));
|
||||
assert_eq!(ev[4], Event::ToolStart { id: "t1".into(), name: "get_basic_info".into(), args: json!({}) });
|
||||
assert_eq!(ev[5], Event::ToolEnd { id: "t1".into(), result: Some(json!("{\"ok\":true}")), error: None });
|
||||
assert_eq!(ev[6], Event::ToolStart { id: "t2".into(), name: "ls".into(), args: json!({"path": "."}) });
|
||||
assert_eq!(ev[7], Event::ToolEnd { id: "t2".into(), result: None, error: Some("denied".into()) });
|
||||
assert_eq!(ev[8], Event::ToolStart { id: "acp-plan-1".into(), name: "TodoWrite".into(), args: json!({"todos": [{"content": "A", "status": "completed"}, {"content": "B", "status": "pending"}]}) });
|
||||
assert_eq!(ev[9], Event::ToolEnd { id: "acp-plan-1".into(), result: Some(json!("ok")), error: None });
|
||||
assert_eq!(ev[10], Event::Context(5000));
|
||||
let Event::TurnEnd { usage: Some(u), session: Some(s), stop_reason: Some(r) } = &ev[11] else { panic!("{:?}", ev[11]) };
|
||||
assert_eq!((u.input, u.output, u.cost_usd, s.as_str(), r.as_str()), (7, 3, Some(0.5), "sess-1", "end_turn"));
|
||||
assert_eq!(ev.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_bridge_when_no_http() {
|
||||
let (ev, sent) = run(STDIO, cfg(), Some("/fake/papercraft-mcp".into()), |_, _| {});
|
||||
assert_eq!(
|
||||
method(&sent, "session/new")["params"]["mcpServers"],
|
||||
json!([{"name": "papercraft", "command": "/fake/papercraft-mcp", "args": [], "env": [{"name": "PAPERCRAFT_MCP_PORT", "value": "29991"}]}])
|
||||
);
|
||||
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"].as_array().unwrap().len(), 2);
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "max_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_auto_and_asked() {
|
||||
let (ev, sent) = run(PERMISSION, cfg(), None, |turn, e| {
|
||||
if let Event::NeedsApproval { id, .. } = e {
|
||||
turn.approve(id, id == "t-del");
|
||||
}
|
||||
});
|
||||
let asked: Vec<_> = ev.iter().filter_map(|e| if let Event::NeedsApproval { id, name, .. } = e { Some((id.as_str(), name.as_str())) } else { None }).collect();
|
||||
assert_eq!(asked, [("t-del", "delete_pages"), ("t-sh", "Run ls")]);
|
||||
let answer = |rpc: u64| sent.iter().find(|v| v["id"] == rpc).unwrap()["result"]["outcome"].clone();
|
||||
assert_eq!(answer(100), json!({"outcome": "selected", "optionId": "allow-once"}));
|
||||
assert_eq!(answer(101), json!({"outcome": "selected", "optionId": "allow-once"}));
|
||||
assert_eq!(answer(102), json!({"outcome": "selected", "optionId": "reject-once"}));
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_sends_notification_and_reports_stopped() {
|
||||
let t = Instant::now();
|
||||
let (ev, sent) = run(CANCEL, cfg(), None, |turn, e| {
|
||||
if matches!(e, Event::Text(_)) {
|
||||
turn.stop();
|
||||
}
|
||||
});
|
||||
assert_eq!(method(&sent, "session/cancel")["params"]["sessionId"], "sess-1");
|
||||
assert!(sent.iter().find(|v| v["method"] == "session/cancel").unwrap().get("id").is_none());
|
||||
// the open tool is closed, the pending permission got `cancelled`
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::ToolEnd { id, error: Some(_), .. } if id == "t1")));
|
||||
assert_eq!(sent.iter().find(|v| v["id"] == 100).unwrap()["result"]["outcome"], json!({"outcome": "cancelled"}));
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
|
||||
assert!(t.elapsed() < Duration::from_secs(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_kills_an_agent_that_ignores_cancel() {
|
||||
let (tx, rx) = channel();
|
||||
let args = ["-c".to_string(), "trap '' INT TERM; while :; do sleep 1; done".to_string()];
|
||||
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, "x", tx, None).unwrap();
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
let t = Instant::now();
|
||||
turn.stop();
|
||||
assert!(t.elapsed() < Duration::from_millis(200), "stop blocked");
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(8)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
|
||||
let t = Instant::now();
|
||||
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
|
||||
thread::sleep(POLL);
|
||||
}
|
||||
assert!(!turn.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_replays_silently_and_skips_the_system_text() {
|
||||
let mut c = cfg();
|
||||
c.session = Some("old".into());
|
||||
let (ev, sent) = run(LOAD, c, None, |_, _| {});
|
||||
let load = &method(&sent, "session/load")["params"];
|
||||
assert_eq!(load["sessionId"], "old");
|
||||
assert!(load["mcpServers"].is_array());
|
||||
assert!(sent.iter().all(|v| v["method"] != "session/new"));
|
||||
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"], json!([{"type": "text", "text": "hello"}]));
|
||||
assert_eq!(ev[0], Event::Session("old".into()));
|
||||
assert_eq!(ev[1], Event::Text("fresh".into()), "replayed history must not surface: {ev:?}");
|
||||
assert!(matches!(&ev[2], Event::TurnEnd { session: Some(s), .. } if s == "old"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_load_falls_back_to_new_session() {
|
||||
let mut c = cfg();
|
||||
c.session = Some("gone".into());
|
||||
let (ev, sent) = run(LOAD_FAILS, c, None, |_, _| {});
|
||||
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"][0]["text"], "SYS");
|
||||
assert_eq!(ev[0], Event::Session("sess-new".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_error_becomes_error_event() {
|
||||
let (ev, _) = run(ERROR, cfg(), None, |_, _| {});
|
||||
assert!(matches!(ev.last(), Some(Event::Error(e)) if e.contains("Authentication required")), "{ev:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_program_is_an_error() {
|
||||
let (tx, _rx) = channel();
|
||||
assert!(spawn(&cfg(), Path::new("/nonexistent/agent"), &[], "x", tx, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_names_and_papercraft_detection() {
|
||||
assert_eq!(canonical_name("mcp__papercraft__get_basic_info"), "get_basic_info");
|
||||
assert_eq!(canonical_name("papercraft_x"), "x");
|
||||
assert_eq!(canonical_name("Read file"), "Read file");
|
||||
assert_eq!(canonical_name("delete_pages (papercraft MCP Server)"), "delete_pages");
|
||||
assert!(is_ours("get_basic_info", Some("other")));
|
||||
assert!(!is_ours("get_basic_info", Some("execute")));
|
||||
assert!(!is_ours("rm -rf papercraft", None));
|
||||
assert!(!is_ours("bogus", None));
|
||||
assert_eq!(mcp_servers("http://x:1/mcp", false, None).unwrap_err().contains("papercraft-mcp"), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_trust_needs_an_announced_papercraft_tool() {
|
||||
// title-only differences, unannounced ids, execute kind and suffixed destructive names all ask
|
||||
let fx = [
|
||||
r#"{"sessionUpdate":"tool_call","toolCallId":"a","title":"get_basic_info (papercraft MCP Server)","kind":"other","rawInput":{}}"#,
|
||||
r#"{"sessionUpdate":"tool_call","toolCallId":"b","title":"Run ls","kind":"execute","rawInput":{}}"#,
|
||||
r#"{"sessionUpdate":"tool_call","toolCallId":"d","title":"delete_pages (papercraft MCP Server)","kind":"other","rawInput":{}}"#,
|
||||
];
|
||||
let (tx, rx) = channel();
|
||||
let (stdin, _) = (Arc::new(Mutex::new(None)), ());
|
||||
let mut c = Client { rx: channel().1, stdin, approvals: channel().1, stopped: Arc::new(AtomicBool::new(false)), tx, next_id: 0, replay: false, pending: HashMap::new(), tools: HashMap::new(), usage: None, cost: None, plans: 0, sid: None };
|
||||
for u in fx {
|
||||
c.update(&serde_json::from_str(u).unwrap());
|
||||
}
|
||||
for (id, title, asked) in [("a", "get_basic_info (papercraft MCP Server)", false), ("b", "Run ls", true), ("d", "delete_pages (papercraft MCP Server)", true), ("zz", "get_basic_info", true), ("b", "get_basic_info", true)] {
|
||||
c.pending.clear();
|
||||
c.permission(json!(1), &json!({"toolCall": {"toolCallId": id, "title": title}, "options": []}));
|
||||
assert_eq!(c.pending.contains_key(id), asked, "{id} {title}");
|
||||
}
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_kills_even_when_the_cancel_write_blocks() {
|
||||
let (tx, rx) = channel();
|
||||
// never reads stdin; a big write fills the pipe and blocks
|
||||
let args = ["-c".to_string(), "trap '' INT TERM; while :; do sleep 1; done".to_string()];
|
||||
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, "x", tx, None).unwrap();
|
||||
*turn.session.lock().unwrap() = Some("s".repeat(1 << 20));
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
turn.stop();
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(8)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! The agent registry: every coding agent PaperCraft knows how to start, as data, plus detection
|
||||
//! on the resolved PATH and the command that launches one over ACP.
|
||||
//!
|
||||
//! Launch args were checked against the agents installed on the dev machine (2026-10-21):
|
||||
//! * Gemini CLI 0.44.1: `gemini --acp` (`--help` lists `--acp`; `--experimental-acp` is deprecated).
|
||||
//! * OpenCode 1.18.30: `opencode acp` ("start ACP (Agent Client Protocol) server").
|
||||
//! * Cursor Agent 2026.03.20: the binary is `cursor-agent` (its help says `agent`); `cursor-agent acp`
|
||||
//! ("Start the Cursor Agent as an ACP server").
|
||||
//! * Pi 0.80.3 has no ACP mode of its own; the `pi-acp` adapter is run with `npx`.
|
||||
//! * Goose: `/opt/homebrew/bin/goose` here is pressly/goose, the SQL migration tool. Block's agent
|
||||
//! has an `acp` subcommand; the migration tool's `--help` never says `acp`, so that is the identity.
|
||||
//! The rest keep their documented command and fail with the agent's own error.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::ui::settings::ChatPrefs;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::ui::settings::CustomAgent;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentKind {
|
||||
/// A program found on PATH that speaks ACP.
|
||||
Native,
|
||||
/// Run with `npx -y <package>`; off until the user enables it, because that runs fetched code.
|
||||
Npx { package: &'static str },
|
||||
/// Claude Code and Codex: proven direct backends, not started through ACP.
|
||||
Direct,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AgentDef {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub binary: &'static str,
|
||||
/// Args after the binary (or after the package for `Npx`) that start ACP mode.
|
||||
pub launch: &'static [&'static str],
|
||||
pub kind: AgentKind,
|
||||
pub docs_url: &'static str,
|
||||
/// Text (case-insensitive) that `--version` or `--help` of the real program contains.
|
||||
pub identity: Option<&'static str>,
|
||||
}
|
||||
|
||||
const fn native(id: &'static str, name: &'static str, binary: &'static str, launch: &'static [&'static str], docs_url: &'static str, identity: Option<&'static str>) -> AgentDef {
|
||||
AgentDef { id, name, binary, launch, kind: AgentKind::Native, docs_url, identity }
|
||||
}
|
||||
|
||||
const fn npx(id: &'static str, name: &'static str, package: &'static str, launch: &'static [&'static str], docs_url: &'static str) -> AgentDef {
|
||||
AgentDef { id, name, binary: "npx", launch, kind: AgentKind::Npx { package }, docs_url, identity: None }
|
||||
}
|
||||
|
||||
pub const AGENTS: &[AgentDef] = &[
|
||||
AgentDef { id: "claude-code", name: "Claude Code", binary: "claude", launch: &[], kind: AgentKind::Direct, docs_url: super::CLAUDE_DOCS, identity: Some("claude code") },
|
||||
AgentDef { id: "codex", name: "Codex", binary: "codex", launch: &[], kind: AgentKind::Direct, docs_url: super::CODEX_DOCS, identity: Some("codex") },
|
||||
native("gemini", "Gemini CLI", "gemini", &["--acp"], "https://github.com/google-gemini/gemini-cli", Some("gemini cli")),
|
||||
native("cursor", "Cursor", "cursor-agent", &["acp"], "https://cursor.com/cli", Some("cursor agent")),
|
||||
native("opencode", "OpenCode", "opencode", &["acp"], "https://opencode.ai/docs", Some("opencode")),
|
||||
native("goose", "Goose", "goose", &["acp"], "https://block.github.io/goose", Some("acp")),
|
||||
native("kimi", "Kimi", "kimi", &["acp"], "https://github.com/MoonshotAI/kimi-cli", None),
|
||||
native("devin", "Devin", "devin", &["acp"], "https://docs.devin.ai", None),
|
||||
native("junie", "Junie", "junie", &["--acp=true"], "https://www.jetbrains.com/junie", None),
|
||||
native("vibe", "Mistral Vibe", "vibe-acp", &[], "https://github.com/mistralai/mistral-vibe", None),
|
||||
native("hermes", "Hermes", "hermes", &["acp"], "https://github.com/NousResearch/hermes-agent", None),
|
||||
native("openhands", "OpenHands", "openhands", &["acp"], "https://docs.all-hands.dev", None),
|
||||
native("stakpak", "Stakpak", "stakpak", &["acp"], "https://github.com/stakpak/agent", None),
|
||||
native("vtcode", "VT Code", "vtcode", &["acp"], "https://github.com/vinhnx/vtcode", None),
|
||||
native("poolside", "Poolside", "pool", &["acp"], "https://poolside.ai", None),
|
||||
native("droid", "Droid", "droid", &["exec", "--output-format", "acp-daemon"], "https://docs.factory.ai", None),
|
||||
npx("qwen", "Qwen Code", "@qwen-code/qwen-code", &["--acp"], "https://github.com/QwenLM/qwen-code"),
|
||||
npx("kilo", "Kilo", "@kilocode/cli", &["acp"], "https://kilo.ai/docs"),
|
||||
npx("cline", "Cline", "cline", &["--acp"], "https://docs.cline.bot"),
|
||||
npx("auggie", "Auggie", "@augmentcode/auggie", &["--acp"], "https://docs.augmentcode.com"),
|
||||
npx("copilot", "Copilot CLI", "@github/copilot", &["--acp"], "https://docs.github.com/copilot"),
|
||||
npx("qoder", "Qoder", "@qoder-ai/qodercli", &["--acp"], "https://qoder.com"),
|
||||
npx("codebuddy", "CodeBuddy", "@tencent-ai/codebuddy-code", &["--acp"], "https://www.codebuddy.ai"),
|
||||
npx("pi", "Pi", "pi-acp", &[], "https://github.com/svkozak/pi-acp"),
|
||||
npx("claude-acp", "Claude Code (ACP adapter)", "@zed-industries/claude-code-acp", &[], "https://github.com/zed-industries/claude-code-acp"),
|
||||
npx("codex-acp", "Codex (ACP adapter)", "@zed-industries/codex-acp", &[], "https://github.com/zed-industries/codex-acp"),
|
||||
];
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static AgentDef> {
|
||||
AGENTS.iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentState {
|
||||
Ready { path: String, version: String },
|
||||
/// An `npx` agent the user has not turned on.
|
||||
NeedsEnable,
|
||||
NotFound,
|
||||
/// A program with the right name that is not the agent (e.g. the `goose` migration tool).
|
||||
WrongProgram { path: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentStatus {
|
||||
pub id: String,
|
||||
pub state: AgentState,
|
||||
}
|
||||
|
||||
/// Registry agents then custom ones.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn detect_all(prefs: &ChatPrefs) -> Vec<AgentStatus> {
|
||||
detect_all_in(prefs, super::path::resolved_path().as_ref())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn detect_all_in(prefs: &ChatPrefs, path: &std::ffi::OsStr) -> Vec<AgentStatus> {
|
||||
// One thread per agent: each probe can wait out its timeout.
|
||||
std::thread::scope(|s| {
|
||||
let jobs: Vec<_> = AGENTS
|
||||
.iter()
|
||||
.map(|d| (d.id.to_owned(), s.spawn(move || detect_def(d, prefs, path))))
|
||||
.chain(prefs.custom_agents.iter().map(|c| (c.id.clone(), s.spawn(move || detect_custom(c, path)))))
|
||||
.collect();
|
||||
jobs.into_iter().map(|(id, j)| AgentStatus { id, state: j.join().unwrap_or(AgentState::NotFound) }).collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); // macOS scans a program on its first run, which can take seconds under load
|
||||
/// `--help` of a node CLI can take a moment longer than `--version`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const HELP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PROBE_MAX_BYTES: u64 = 64 * 1024;
|
||||
|
||||
/// The override from Settings, if any. The old `claude_path`/`codex_path` fields win for their
|
||||
/// agent until Settings writes `agents` instead.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn path_override<'a>(id: &str, prefs: &'a ChatPrefs) -> Option<&'a str> {
|
||||
let legacy = match id {
|
||||
"claude-code" => prefs.claude_path.as_deref(),
|
||||
"codex" => prefs.codex_path.as_deref(),
|
||||
_ => None,
|
||||
};
|
||||
legacy.or_else(|| prefs.agents.get(id)?.path.as_deref()).map(str::trim).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn args_override<'a>(id: &str, prefs: &'a ChatPrefs) -> Option<&'a str> {
|
||||
prefs.agents.get(id)?.args.as_deref().map(str::trim).filter(|a| !a.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_enabled(def: &AgentDef, prefs: &ChatPrefs) -> bool {
|
||||
!matches!(def.kind, AgentKind::Npx { .. }) || prefs.agents.get(def.id).is_some_and(|a| a.enabled)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn locate(binary: &str, over: Option<&str>, path: &std::ffi::OsStr) -> Option<PathBuf> {
|
||||
match over {
|
||||
Some(p) => PathBuf::from(p).is_file().then(|| PathBuf::from(p)),
|
||||
None => super::path::which_in(binary, path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn detect_def(def: &AgentDef, prefs: &ChatPrefs, path: &std::ffi::OsStr) -> AgentState {
|
||||
let over = path_override(def.id, prefs);
|
||||
if !is_enabled(def, prefs) {
|
||||
return AgentState::NeedsEnable;
|
||||
}
|
||||
// An npx agent needs npx itself; a path override points at that program instead.
|
||||
let Some(bin) = locate(def.binary, over, path) else { return AgentState::NotFound };
|
||||
let shown = bin.display().to_string();
|
||||
if matches!(def.kind, AgentKind::Npx { .. }) {
|
||||
return AgentState::Ready { path: shown, version: String::new() };
|
||||
}
|
||||
let version = probe(&bin, "--version", path, PROBE_TIMEOUT);
|
||||
if let Some(want) = def.identity {
|
||||
let hit = |o: &Option<String>| o.as_ref().is_some_and(|o| o.to_lowercase().contains(want));
|
||||
if !hit(&version) && !hit(&probe(&bin, "--help", path, HELP_TIMEOUT)) {
|
||||
return AgentState::WrongProgram { path: shown };
|
||||
}
|
||||
}
|
||||
AgentState::Ready { path: shown, version: version.as_deref().and_then(|v| v.lines().find(|l| !l.trim().is_empty())).unwrap_or_default().trim().to_owned() }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn detect_custom(c: &CustomAgent, path: &std::ffi::OsStr) -> AgentState {
|
||||
match custom_program(c, path) {
|
||||
Some(p) => AgentState::Ready { path: p.display().to_string(), version: String::new() },
|
||||
None => AgentState::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn custom_program(c: &CustomAgent, path: &std::ffi::OsStr) -> Option<PathBuf> {
|
||||
let cmd = c.command.trim();
|
||||
if cmd.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if cmd.contains(['/', '\\']) {
|
||||
return PathBuf::from(cmd).is_file().then(|| PathBuf::from(cmd));
|
||||
}
|
||||
super::path::which_in(cmd, path)
|
||||
}
|
||||
|
||||
/// Runs `bin arg` and returns stdout + stderr; None when it fails to start or times out. No
|
||||
/// credentials in the environment, PATH as the agents get it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn probe(bin: &std::path::Path, arg: &str, path: &std::ffi::OsStr, timeout: std::time::Duration) -> Option<String> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.arg(arg).env("PATH", path).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
for k in super::path::CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
// ETXTBSY (26): a binary just written (installer, test) is briefly still open for writing.
|
||||
let mut child = (0..5).find_map(|_| match cmd.spawn() {
|
||||
Err(e) if e.raw_os_error() == Some(26) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
None
|
||||
}
|
||||
r => Some(r),
|
||||
})?.ok()?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
for pipe in [child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>), child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>)].into_iter().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = pipe.take(PROBE_MAX_BYTES).read_to_string(&mut s);
|
||||
let _ = tx.send(s);
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
// Both pipes close when the program exits; a hung program hits the deadline and is killed.
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let mut out = String::new();
|
||||
let done = loop {
|
||||
match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) {
|
||||
Ok(s) => out.push_str(&s),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break true,
|
||||
Err(_) => break false,
|
||||
}
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
done.then_some(out)
|
||||
}
|
||||
|
||||
/// What the ACP backend runs for this agent: program and args. None for an unknown id, a
|
||||
/// `Direct` agent, an `npx` agent that is not enabled, or a program that is not on PATH.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn launch_command(id: &str, prefs: &ChatPrefs) -> Option<(PathBuf, Vec<String>)> {
|
||||
launch_command_in(id, prefs, super::path::resolved_path().as_ref())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn launch_command_in(id: &str, prefs: &ChatPrefs, path: &std::ffi::OsStr) -> Option<(PathBuf, Vec<String>)> {
|
||||
if let Some(c) = prefs.custom_agents.iter().find(|c| c.id == id) {
|
||||
return Some((custom_program(c, path)?, split(&c.args)));
|
||||
}
|
||||
let def = find(id)?;
|
||||
if !is_enabled(def, prefs) {
|
||||
return None;
|
||||
}
|
||||
let program = locate(def.binary, path_override(id, prefs), path)?;
|
||||
let args = args_override(id, prefs).map_or_else(|| def.launch.iter().map(|a| (*a).to_owned()).collect(), split);
|
||||
match def.kind {
|
||||
AgentKind::Native => Some((program, args)),
|
||||
AgentKind::Npx { package } => Some((program, ["-y".to_owned(), package.to_owned()].into_iter().chain(args).collect())),
|
||||
AgentKind::Direct => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: whitespace split, no quoting; add shlex if a path with spaces is ever needed in args.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn split(args: &str) -> Vec<String> {
|
||||
args.split_whitespace().map(String::from).collect()
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui::settings::AgentPrefs;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn fake_dir(scripts: &[(&str, &str)]) -> (PathBuf, OsString) {
|
||||
let dir = std::env::temp_dir().join(format!("pc-agents-{}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, body) in scripts {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let path = dir.clone().into_os_string();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn state(all: &[AgentStatus], id: &str) -> AgentState {
|
||||
all.iter().find(|s| s.id == id).unwrap().state.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_ids_are_unique_and_launchable() {
|
||||
let mut ids: Vec<_> = AGENTS.iter().map(|a| a.id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), AGENTS.len());
|
||||
assert!(AGENTS.iter().all(|a| !a.name.is_empty() && !a.binary.is_empty() && a.docs_url.starts_with("https://")));
|
||||
assert!(AGENTS.iter().all(|a| !matches!(a.kind, AgentKind::Native) || !a.launch.is_empty() || a.id == "vibe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_ready_wrong_missing_and_npx_states() {
|
||||
let (dir, path) = fake_dir(&[
|
||||
("gemini", r#"if [ "$1" = --version ]; then echo 0.1.0; else echo "Gemini CLI help"; fi"#),
|
||||
// The migration tool: version string and help with no "acp".
|
||||
("goose", r#"if [ "$1" = --version ]; then echo "goose version: v3.27.1"; else echo "Usage: goose DRIVER DBSTRING"; fi"#),
|
||||
("opencode", r#"echo "opencode 1.0""#),
|
||||
("npx", "echo npx"),
|
||||
]);
|
||||
let mut prefs = ChatPrefs::default();
|
||||
let all = detect_all_in(&prefs, &path);
|
||||
assert_eq!(all.len(), AGENTS.len());
|
||||
let shown = |n: &str| dir.join(n).display().to_string();
|
||||
assert_eq!(state(&all, "gemini"), AgentState::Ready { path: shown("gemini"), version: "0.1.0".into() });
|
||||
assert_eq!(state(&all, "opencode"), AgentState::Ready { path: shown("opencode"), version: "opencode 1.0".into() });
|
||||
assert_eq!(state(&all, "goose"), AgentState::WrongProgram { path: shown("goose") });
|
||||
assert_eq!(state(&all, "cursor"), AgentState::NotFound);
|
||||
assert_eq!(state(&all, "pi"), AgentState::NeedsEnable);
|
||||
prefs.agents.insert("pi".into(), AgentPrefs { enabled: true, ..Default::default() });
|
||||
assert_eq!(state(&detect_all_in(&prefs, &path), "pi"), AgentState::Ready { path: shown("npx"), version: String::new() });
|
||||
// Enabled but npx missing.
|
||||
assert_eq!(state(&detect_all_in(&prefs, OsStr::new("/nonexistent")), "pi"), AgentState::NotFound);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn path_override_wins_and_hung_program_times_out() {
|
||||
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "sleep 30")]);
|
||||
let mut prefs = ChatPrefs::default();
|
||||
prefs.agents.insert("opencode".into(), AgentPrefs { path: Some("/nonexistent/opencode".into()), ..Default::default() });
|
||||
assert_eq!(state(&detect_all_in(&prefs, &path), "opencode"), AgentState::NotFound);
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(probe(&dir.join("hang"), "--version", &path, std::time::Duration::from_millis(200)), None);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_commands_for_native_npx_custom_and_direct() {
|
||||
let (dir, path) = fake_dir(&[("gemini", ""), ("npx", ""), ("my-agent", "")]);
|
||||
let mut prefs = ChatPrefs::default();
|
||||
prefs.custom_agents.push(CustomAgent { id: "mine".into(), name: "Mine".into(), command: "my-agent".into(), args: "serve --stdio".into() });
|
||||
let at = |n: &str| dir.join(n);
|
||||
assert_eq!(launch_command_in("gemini", &prefs, &path), Some((at("gemini"), vec!["--acp".into()])));
|
||||
assert_eq!(launch_command_in("mine", &prefs, &path), Some((at("my-agent"), vec!["serve".into(), "--stdio".into()])));
|
||||
assert_eq!(launch_command_in("pi", &prefs, &path), None);
|
||||
prefs.agents.insert("pi".into(), AgentPrefs { enabled: true, ..Default::default() });
|
||||
assert_eq!(launch_command_in("pi", &prefs, &path), Some((at("npx"), vec!["-y".into(), "pi-acp".into()])));
|
||||
prefs.agents.insert("gemini".into(), AgentPrefs { args: Some("--acp --debug".into()), ..Default::default() });
|
||||
assert_eq!(launch_command_in("gemini", &prefs, &path).unwrap().1, ["--acp", "--debug"]);
|
||||
assert_eq!(launch_command_in("claude-code", &prefs, &path), None);
|
||||
assert_eq!(launch_command_in("nope", &prefs, &path), None);
|
||||
assert_eq!(launch_command_in("cursor", &prefs, &path), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Parser for `claude -p --output-format stream-json --verbose [--include-partial-messages]` lines.
|
||||
//! Pure: one JSON line in, zero or more [`Event`]s out.
|
||||
//!
|
||||
//! Without partial messages the text arrives in whole `assistant` messages ([`parse_line`]). With
|
||||
//! `--include-partial-messages` the CLI emits `stream_event` deltas AND the whole `assistant`
|
||||
//! messages, so [`parse_line_partial`] takes text/thinking from the deltas and only tool calls
|
||||
//! from the whole messages (their input is complete there).
|
||||
|
||||
use super::events::{content_text, str_of, Event};
|
||||
use super::Usage;
|
||||
use serde_json::Value;
|
||||
|
||||
/// MCP tools reach the model as `mcp__<server>__<tool>`; the UI wants the bare tool name.
|
||||
const MCP_PREFIX: &str = "mcp__papercraft__";
|
||||
|
||||
pub fn parse_line(line: &str) -> Vec<Event> {
|
||||
parse(line, false)
|
||||
}
|
||||
|
||||
pub fn parse_line_partial(line: &str) -> Vec<Event> {
|
||||
parse(line, true)
|
||||
}
|
||||
|
||||
fn parse(line: &str, partial: bool) -> Vec<Event> {
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else { return vec![] };
|
||||
match v["type"].as_str() {
|
||||
Some("system") if v["subtype"] == "init" => v["session_id"].as_str().map(|s| Event::Session(s.into())).into_iter().collect(),
|
||||
Some("assistant") => assistant(&v, partial),
|
||||
Some("user") => tool_results(&v),
|
||||
Some("stream_event") if partial => delta(&v["event"]),
|
||||
Some("result") => result(&v),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens in the context at one model call: input plus cache, from an `assistant` line's
|
||||
/// `message.usage`. Sub-agent lines (`parent_tool_use_id` set) count against their own context.
|
||||
pub fn context_of(line: &str) -> Option<u64> {
|
||||
let v = serde_json::from_str::<Value>(line).ok()?;
|
||||
if v["type"] != "assistant" || !v["parent_tool_use_id"].is_null() {
|
||||
return None;
|
||||
}
|
||||
let u = &v["message"]["usage"];
|
||||
u.is_object().then(|| ["input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"].iter().map(|k| u[*k].as_u64().unwrap_or(0)).sum())
|
||||
}
|
||||
|
||||
fn blocks(v: &Value) -> &[Value] {
|
||||
v["message"]["content"].as_array().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
fn assistant(v: &Value, partial: bool) -> Vec<Event> {
|
||||
let mut out = vec![];
|
||||
for b in blocks(v) {
|
||||
match b["type"].as_str() {
|
||||
Some("text") if !partial => push_text(&mut out, b["text"].as_str(), false),
|
||||
Some("thinking") if !partial => push_text(&mut out, b["thinking"].as_str(), true),
|
||||
Some("tool_use") => {
|
||||
let name = str_of(&b["name"]);
|
||||
out.push(Event::ToolStart {
|
||||
id: str_of(&b["id"]),
|
||||
name: name.strip_prefix(MCP_PREFIX).map_or(name.clone(), str::to_string),
|
||||
args: b["input"].clone(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_text(out: &mut Vec<Event>, s: Option<&str>, thinking: bool) {
|
||||
let Some(s) = s.filter(|s| !s.is_empty()) else { return };
|
||||
out.push(if thinking { Event::Thinking(s.into()) } else { Event::Text(s.into()) });
|
||||
}
|
||||
|
||||
fn delta(e: &Value) -> Vec<Event> {
|
||||
let mut out = vec![];
|
||||
if e["type"] != "content_block_delta" {
|
||||
return out;
|
||||
}
|
||||
let d = &e["delta"];
|
||||
match d["type"].as_str() {
|
||||
Some("text_delta") => push_text(&mut out, d["text"].as_str(), false),
|
||||
Some("thinking_delta") => push_text(&mut out, d["thinking"].as_str(), true),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_results(v: &Value) -> Vec<Event> {
|
||||
blocks(v)
|
||||
.iter()
|
||||
.filter(|b| b["type"] == "tool_result")
|
||||
.map(|b| {
|
||||
let id = str_of(&b["tool_use_id"]);
|
||||
if b["is_error"].as_bool() == Some(true) {
|
||||
Event::ToolEnd { id, result: None, error: Some(content_text(&b["content"])) }
|
||||
} else {
|
||||
Event::ToolEnd { id, result: Some(b["content"].clone()), error: None }
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn result(v: &Value) -> Vec<Event> {
|
||||
let mut out = vec![];
|
||||
if v["is_error"].as_bool() == Some(true) {
|
||||
let msg = v["result"].as_str().filter(|s| !s.is_empty()).unwrap_or("Claude Code reported an error");
|
||||
out.push(Event::Error(msg.into()));
|
||||
}
|
||||
let u = &v["usage"];
|
||||
let usage = u.is_object().then(|| Usage {
|
||||
input: u["input_tokens"].as_u64().unwrap_or(0),
|
||||
output: u["output_tokens"].as_u64().unwrap_or(0),
|
||||
cache_read: u["cache_read_input_tokens"].as_u64().unwrap_or(0),
|
||||
cache_write: u["cache_creation_input_tokens"].as_u64().unwrap_or(0),
|
||||
cost_usd: v["total_cost_usd"].as_f64(),
|
||||
});
|
||||
out.push(Event::TurnEnd {
|
||||
usage,
|
||||
session: v["session_id"].as_str().map(Into::into),
|
||||
stop_reason: v["stop_reason"].as_str().map(Into::into),
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use process::{args, detect, start};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod process {
|
||||
use super::*;
|
||||
use crate::chat::events::{Turn, TurnConfig};
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc::Sender, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const STOP_GRACE: Duration = Duration::from_secs(3);
|
||||
const STOP_POLL: Duration = Duration::from_millis(50);
|
||||
/// Internal tool lookup the CLI does before calling a deferred MCP tool; not worth a card.
|
||||
const HIDDEN_TOOL: &str = "ToolSearch";
|
||||
|
||||
/// The `claude` binary (override, else first on PATH) and its version, e.g. `2.1.306`.
|
||||
pub fn detect(binary: Option<&Path>) -> Option<(PathBuf, String)> {
|
||||
let path = match binary {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => crate::chat::path::which("claude")?,
|
||||
};
|
||||
let out = clean_env(&mut Command::new(&path)).arg("--version").output().ok().filter(|o| o.status.success())?;
|
||||
let version = String::from_utf8_lossy(&out.stdout).split_whitespace().next()?.to_string();
|
||||
Some((path, version))
|
||||
}
|
||||
|
||||
/// `system_file` holds the system prompt (read by the CLI, so it is not in `ps` output).
|
||||
pub fn args(cfg: &TurnConfig, system_file: Option<&Path>) -> Vec<String> {
|
||||
let mcp = serde_json::json!({"mcpServers": {"papercraft": {"type": "http", "url": cfg.mcp_url}}});
|
||||
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__*",
|
||||
]
|
||||
.map(String::from)
|
||||
.into();
|
||||
if let Some(f) = system_file {
|
||||
a.extend(["--append-system-prompt-file".into(), f.display().to_string()]);
|
||||
}
|
||||
for (flag, v) in [("--model", &cfg.model), ("--effort", &cfg.effort), ("--resume", &cfg.session)] {
|
||||
if let Some(v) = v {
|
||||
a.extend([flag.into(), v.clone()]);
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Keys that would make the CLI bill an API account instead of using its own login; the chat
|
||||
/// must run on the user's CLI login (spec, Security), so they are never inherited.
|
||||
const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
|
||||
fn clean_env(cmd: &mut Command) -> &mut Command {
|
||||
for k in CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
crate::chat::path::apply(cmd)
|
||||
}
|
||||
|
||||
/// Shared by the waiter thread and `stop`; only signalled while unreaped (`try_wait` is `None`),
|
||||
/// and always reaped under the lock, so a recycled PID is never signalled.
|
||||
struct Proc {
|
||||
child: Arc<Mutex<Child>>,
|
||||
running: Arc<AtomicBool>,
|
||||
/// Set by `stop`: the exit that follows is the user's, not a failure.
|
||||
stopped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
fn interrupt(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: plain syscall on the PID of a child that is still unreaped (caller holds the lock).
|
||||
unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGINT) };
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
fn reap(mut child: Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// Off the UI thread: waits out the grace period, then kills and reaps a child that ignored SIGINT.
|
||||
fn escalate(child: &Mutex<Child>) {
|
||||
let t = Instant::now();
|
||||
loop {
|
||||
let mut c = child.lock().unwrap();
|
||||
if !matches!(c.try_wait(), Ok(None)) {
|
||||
return;
|
||||
}
|
||||
if t.elapsed() >= STOP_GRACE {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
return;
|
||||
}
|
||||
drop(c);
|
||||
std::thread::sleep(STOP_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
impl Turn for Proc {
|
||||
fn stop(&mut self) {
|
||||
{
|
||||
let mut c = self.child.lock().unwrap();
|
||||
if !matches!(c.try_wait(), Ok(None)) {
|
||||
return;
|
||||
}
|
||||
self.stopped.store(true, Ordering::SeqCst);
|
||||
interrupt(&mut c);
|
||||
}
|
||||
let child = self.child.clone();
|
||||
std::thread::spawn(move || escalate(&child));
|
||||
}
|
||||
fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// The system prompt as a file under the project's tmp folder; the caller deletes it when the
|
||||
/// turn is over.
|
||||
fn write_system_file(cfg: &TurnConfig) -> Result<Option<PathBuf>, String> {
|
||||
if cfg.system.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dir = cfg.project_dir.join(crate::chat::TMP_DIR);
|
||||
let file = dir.join(format!("system-{:016x}.txt", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(&file, &cfg.system)).map_err(|e| format!("could not write the system prompt: {e}"))?;
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
pub fn start(cfg: &TurnConfig, prompt: &str, tx: Sender<Event>) -> Result<Box<dyn Turn>, String> {
|
||||
let (bin, _) = detect(cfg.binary.as_deref()).ok_or("Claude Code (`claude`) was not found")?;
|
||||
let system_file = write_system_file(cfg)?;
|
||||
let removed = system_file.clone();
|
||||
let cleanup = move || {
|
||||
if let Some(f) = &removed {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
};
|
||||
let mut child = clean_env(&mut Command::new(bin))
|
||||
.args(args(cfg, system_file.as_deref()))
|
||||
.current_dir(&cfg.project_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
cleanup();
|
||||
format!("could not start claude: {e}")
|
||||
})?;
|
||||
let (Some(mut stdin), Some(stdout), Some(mut stderr)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else {
|
||||
reap(child);
|
||||
cleanup();
|
||||
return Err("could not open the claude pipes".into());
|
||||
};
|
||||
let msg = serde_json::json!({"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": prompt}]}});
|
||||
// Dropping stdin after the one message makes the CLI finish the turn and exit.
|
||||
if let Err(e) = writeln!(stdin, "{msg}") {
|
||||
drop(stdin);
|
||||
reap(child);
|
||||
cleanup();
|
||||
return Err(format!("could not send the message: {e}"));
|
||||
}
|
||||
drop(stdin);
|
||||
let err_thread = std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = stderr.read_to_string(&mut s);
|
||||
s
|
||||
});
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let turn = Proc { child: child.clone(), running: running.clone(), stopped: stopped.clone() };
|
||||
std::thread::spawn(move || {
|
||||
let (mut ended, mut hidden) = (false, HashSet::new());
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
if let Some(n) = context_of(&line) {
|
||||
let _ = tx.send(Event::Context(n));
|
||||
}
|
||||
for ev in parse_line_partial(&line) {
|
||||
match &ev {
|
||||
Event::ToolStart { id, name, .. } if name == HIDDEN_TOOL => {
|
||||
hidden.insert(id.clone());
|
||||
continue;
|
||||
}
|
||||
Event::ToolEnd { id, .. } if hidden.contains(id) => continue,
|
||||
Event::TurnEnd { .. } | Event::Error(_) => ended = true,
|
||||
_ => {}
|
||||
}
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
}
|
||||
// Poll under the lock (never block in `wait` holding it) so `stop` can still signal.
|
||||
let status = loop {
|
||||
let r = child.lock().unwrap().try_wait();
|
||||
match r {
|
||||
Ok(None) => std::thread::sleep(STOP_POLL),
|
||||
r => break r,
|
||||
}
|
||||
};
|
||||
let stderr = err_thread.join().unwrap_or_default();
|
||||
cleanup();
|
||||
if !ended {
|
||||
let ok = status.as_ref().is_ok_and(|s| s.as_ref().is_some_and(|s| s.success()));
|
||||
let ev = if ok || stopped.load(Ordering::SeqCst) {
|
||||
Event::TurnEnd { usage: None, session: None, stop_reason: Some("stopped".into()).filter(|_| !ok) }
|
||||
} else {
|
||||
let msg = stderr.trim();
|
||||
Event::Error(if msg.is_empty() { format!("claude exited: {status:?}") } else { msg.into() })
|
||||
};
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
});
|
||||
Ok(Box::new(turn))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
#[test]
|
||||
fn args_shape() {
|
||||
let cfg = TurnConfig {
|
||||
mcp_url: "http://127.0.0.1:1/mcp".into(),
|
||||
system: "SYS".into(),
|
||||
model: Some("m".into()),
|
||||
session: Some("sid".into()),
|
||||
..Default::default()
|
||||
};
|
||||
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("--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");
|
||||
assert_eq!(at("--resume"), "sid");
|
||||
assert!(!a.contains(&"--effort".to_string()));
|
||||
let mcp: Value = serde_json::from_str(at("--mcp-config")).unwrap();
|
||||
assert_eq!(mcp["mcpServers"]["papercraft"]["url"], "http://127.0.0.1:1/mcp");
|
||||
assert!(a.contains(&"--strict-mcp-config".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_are_not_inherited() {
|
||||
let mut c = Command::new("x");
|
||||
clean_env(&mut c);
|
||||
let removed: Vec<_> = c.get_envs().filter(|(_, v)| v.is_none()).map(|(k, _)| k.to_string_lossy().into_owned()).collect();
|
||||
for k in CREDENTIAL_ENV {
|
||||
assert!(removed.iter().any(|r| r == k), "{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fake_cli(name: &str, body: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let p = std::env::temp_dir().join(format!("papercraft-fake-claude-{}-{name}", std::process::id()));
|
||||
std::fs::write(&p, format!("#!/bin/sh\n[ \"$1\" = --version ] && {{ echo 1.0; exit 0; }}\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_interrupts_and_reaps() {
|
||||
let cfg = TurnConfig { binary: Some(fake_cli("stop", "exec sleep 30")), project_dir: std::env::temp_dir(), ..Default::default() };
|
||||
let (tx, rx) = channel();
|
||||
let mut turn = start(&cfg, "hi", tx).unwrap();
|
||||
let t = Instant::now();
|
||||
turn.stop();
|
||||
assert!(t.elapsed() < Duration::from_millis(200), "stop blocked for {:?}", t.elapsed());
|
||||
// A stop is the user's choice: it ends the turn, it is not an error.
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(5)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
|
||||
let t = Instant::now();
|
||||
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(!turn.is_running());
|
||||
turn.stop(); // after the reap: must be a no-op
|
||||
}
|
||||
|
||||
/// SIGINT is ignored, so the waiter thread must kill the child after the grace period.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_kills_a_child_that_ignores_sigint() {
|
||||
let ready = std::env::temp_dir().join(format!("papercraft-stop-ready-{}", std::process::id()));
|
||||
let body = format!("trap '' INT; touch '{}'; exec sleep 30", ready.display());
|
||||
let cfg = TurnConfig { binary: Some(fake_cli("ignore", &body)), project_dir: std::env::temp_dir(), ..Default::default() };
|
||||
let (tx, rx) = channel();
|
||||
let mut turn = start(&cfg, "hi", tx).unwrap();
|
||||
let t = Instant::now();
|
||||
while !ready.exists() && t.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
turn.stop();
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(8)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
|
||||
assert!(!turn.is_running());
|
||||
std::fs::remove_file(ready).ok();
|
||||
}
|
||||
|
||||
/// The system prompt travels in a file under the project's tmp folder, never in argv, and
|
||||
/// the file is gone when the turn is over.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn system_prompt_goes_through_a_file_that_is_removed_afterwards() {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-claude-sys-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let body = r#"for a; do [ "$a" = SECRET-PROMPT ] && exit 3; done; cat >/dev/null; echo '{"type":"result","session_id":"s"}'"#;
|
||||
let cfg = TurnConfig { binary: Some(fake_cli("sysfile", body)), project_dir: dir.clone(), system: "SECRET-PROMPT".into(), ..Default::default() };
|
||||
let (tx, rx) = channel();
|
||||
let turn = start(&cfg, "hi", tx).unwrap();
|
||||
let ev = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(matches!(ev, Event::TurnEnd { .. }), "{ev:?}");
|
||||
let t = Instant::now();
|
||||
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let left: Vec<_> = std::fs::read_dir(dir.join(crate::chat::TMP_DIR)).unwrap().flatten().collect();
|
||||
assert!(left.is_empty(), "{left:?}");
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_stdin_write_reaps_child() {
|
||||
// Exits at once without reading; the large prompt overflows the pipe and fails the write.
|
||||
let cfg = TurnConfig { binary: Some(fake_cli("nostdin", "exit 0")), project_dir: std::env::temp_dir(), ..Default::default() };
|
||||
let (tx, _rx) = channel();
|
||||
let big = "x".repeat(1 << 20);
|
||||
assert!(start(&cfg, &big, tx).is_err());
|
||||
}
|
||||
|
||||
/// Needs `claude` on PATH and PAPERCRAFT_TEST_CLAUDE=1; launches its own app instance.
|
||||
#[test]
|
||||
fn end_to_end() {
|
||||
if std::env::var("PAPERCRAFT_TEST_CLAUDE").as_deref() != Ok("1") || detect(None).is_none() {
|
||||
return;
|
||||
}
|
||||
let home = std::env::temp_dir().join(format!("papercraft-chat-e2e-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let exe = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/debug/papercraft");
|
||||
let mut app = Command::new(exe)
|
||||
.env("PAPERCRAFT_HOME", &home)
|
||||
.env("PAPERCRAFT_MCP_PORT", "29984")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("build target/debug/papercraft first");
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
let cfg = TurnConfig {
|
||||
project_dir: home.clone(),
|
||||
mcp_url: "http://127.0.0.1:29984/mcp".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (tx, rx) = channel();
|
||||
let r = start(&cfg, "Create a project named T via create_file, open it, then call get_basic_info", tx);
|
||||
let mut ev = vec![];
|
||||
if r.is_ok() {
|
||||
while let Ok(e) = rx.recv_timeout(Duration::from_secs(240)) {
|
||||
let done = matches!(e, Event::TurnEnd { .. } | Event::Error(_));
|
||||
ev.push(e);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = app.kill();
|
||||
let _ = app.wait();
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
r.map(|_| ()).unwrap();
|
||||
assert!(matches!(ev.first(), Some(Event::Session(_))), "{ev:?}");
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::ToolStart { name, .. } if name == "get_basic_info")), "{ev:?}");
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::ToolEnd { .. })), "{ev:?}");
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })), "{ev:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// Real recordings (claude 2.1, opus-5-5) with SessionStart hook lines, plugin/tool lists,
|
||||
// paths and signatures stripped. The tool run used an own PaperCraft instance with no project
|
||||
// open, so get_basic_info returns an error result.
|
||||
const HI: &str = include_str!("../../tests/fixtures/chat/claude_code_hi.jsonl");
|
||||
const TOOL: &str = include_str!("../../tests/fixtures/chat/claude_code_tool.jsonl");
|
||||
|
||||
fn all(f: &str, p: fn(&str) -> Vec<Event>) -> Vec<Event> {
|
||||
f.lines().flat_map(p).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hi() {
|
||||
let ev = all(HI, parse_line);
|
||||
assert_eq!(ev.len(), 3);
|
||||
assert!(matches!(&ev[0], Event::Session(s) if s.len() == 36));
|
||||
assert_eq!(ev[1], Event::Text("Hi there, friend.".into()));
|
||||
let Event::TurnEnd { usage: Some(u), session: Some(s), stop_reason } = &ev[2] else { panic!("{ev:?}") };
|
||||
assert_eq!((u.input, u.output, u.cache_read, u.cache_write), (2, 10, 8857, 27039));
|
||||
assert!(u.cost_usd.unwrap() > 0.0);
|
||||
assert_eq!(s.len(), 36);
|
||||
assert_eq!(stop_reason.as_deref(), Some("end_turn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_run_whole_messages() {
|
||||
let ev: Vec<Event> = all(TOOL, parse_line).into_iter().filter(|e| !matches!(e, Event::Session(_))).collect();
|
||||
assert_eq!(ev.len(), 6, "{ev:?}");
|
||||
let Event::ToolStart { name, args, .. } = &ev[0] else { panic!() };
|
||||
assert_eq!((name.as_str(), args), ("ToolSearch", &json!({"query":"select:mcp__papercraft__get_basic_info","max_results":1})));
|
||||
assert!(matches!(&ev[1], Event::ToolEnd { result: Some(_), error: None, .. }));
|
||||
let Event::ToolStart { id, name, args } = &ev[2] else { panic!() };
|
||||
assert_eq!((name.as_str(), args), ("get_basic_info", &json!({})));
|
||||
let Event::ToolEnd { id: end_id, result: None, error: Some(e) } = &ev[3] else { panic!("{:?}", ev[3]) };
|
||||
assert_eq!(end_id, id);
|
||||
assert!(e.starts_with("No project is open"));
|
||||
assert!(matches!(&ev[4], Event::Text(t) if t.contains("get_basic_info")));
|
||||
assert!(matches!(&ev[5], Event::TurnEnd { stop_reason: Some(r), .. } if r == "end_turn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_mode_streams_deltas_without_duplicates() {
|
||||
let whole = all(TOOL, parse_line);
|
||||
assert!(whole.iter().any(|e| matches!(e, Event::Session(_))));
|
||||
let part = all(TOOL, parse_line_partial);
|
||||
let text: String = part.iter().filter_map(|e| if let Event::Text(t) = e { Some(t.as_str()) } else { None }).collect();
|
||||
let full: String = whole.iter().filter_map(|e| if let Event::Text(t) = e { Some(t.as_str()) } else { None }).collect();
|
||||
assert_eq!(text, full);
|
||||
assert!(part.iter().filter(|e| matches!(e, Event::Text(_))).count() > 1);
|
||||
assert_eq!(part.iter().filter(|e| matches!(e, Event::ToolStart { .. })).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_is_the_last_calls_input_plus_cache_not_the_turn_total() {
|
||||
let call = |input, read, write, parent: &str| {
|
||||
format!(r#"{{"type":"assistant","parent_tool_use_id":{parent},"message":{{"content":[],"usage":{{"input_tokens":{input},"cache_read_input_tokens":{read},"cache_creation_input_tokens":{write},"output_tokens":9}}}}}}"#)
|
||||
};
|
||||
assert_eq!(context_of(&call(5, 1000, 200, "null")), Some(1205));
|
||||
assert_eq!(context_of(&call(5, 1000, 200, r#""toolu_sub""#)), None, "a sub-agent has its own context");
|
||||
assert_eq!(context_of(r#"{"type":"assistant","message":{"content":[]}}"#), None);
|
||||
assert_eq!(context_of(r#"{"type":"result","usage":{"input_tokens":365000}}"#), None, "the result line sums the whole turn");
|
||||
let from_fixture: Vec<u64> = TOOL.lines().filter_map(context_of).collect();
|
||||
assert!(!from_fixture.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_and_errors() {
|
||||
assert!(parse_line("not json").is_empty());
|
||||
assert!(parse_line(r#"{"type":"rate_limit_event"}"#).is_empty());
|
||||
let ev = parse_line(r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#);
|
||||
assert_eq!(ev[0], Event::Error("boom".into()));
|
||||
assert!(matches!(&ev[1], Event::TurnEnd { usage: None, session: Some(s), .. } if s == "s"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Parser for `codex exec --json` JSONL events. Stateful only because the thread id arrives in
|
||||
//! `thread.started` and belongs on the `TurnEnd`.
|
||||
|
||||
use super::events::{content_text, str_of, Event};
|
||||
use super::Usage;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Parser {
|
||||
thread: Option<String>,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn parse_line(&mut self, line: &str) -> Vec<Event> {
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else { return vec![] };
|
||||
match v["type"].as_str() {
|
||||
Some("thread.started") => {
|
||||
self.thread = v["thread_id"].as_str().map(Into::into);
|
||||
vec![]
|
||||
}
|
||||
Some("item.started") => item(&v["item"], false),
|
||||
Some("item.completed") => item(&v["item"], true),
|
||||
Some("turn.completed") => vec![Event::TurnEnd {
|
||||
usage: usage(&v["usage"]),
|
||||
session: self.thread.clone(),
|
||||
stop_reason: None,
|
||||
}],
|
||||
Some("turn.failed") => vec![Event::Error(error_text(&v["error"], "turn failed"))],
|
||||
Some("error") => vec![Event::Error(error_text(&v, "codex error"))],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_text(v: &Value, fallback: &str) -> String {
|
||||
let m = v["message"].as_str().or_else(|| v.as_str());
|
||||
m.unwrap_or(fallback).into()
|
||||
}
|
||||
|
||||
fn usage(u: &Value) -> Option<Usage> {
|
||||
if !u.is_object() {
|
||||
return None;
|
||||
}
|
||||
let get = |k: &str| u[k].as_u64().unwrap_or(0);
|
||||
let cached = get("cached_input_tokens");
|
||||
Some(Usage {
|
||||
// Codex counts cached tokens inside input_tokens; Usage keeps them apart.
|
||||
input: get("input_tokens").saturating_sub(cached),
|
||||
output: get("output_tokens"),
|
||||
cache_read: cached,
|
||||
cache_write: get("cache_write_input_tokens"),
|
||||
cost_usd: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn item(i: &Value, done: bool) -> Vec<Event> {
|
||||
let id = str_of(&i["id"]);
|
||||
match (i["type"].as_str(), done) {
|
||||
(Some("agent_message"), true) => text(i, false),
|
||||
(Some("reasoning"), true) => text(i, true),
|
||||
(Some("mcp_tool_call"), false) => {
|
||||
vec![Event::ToolStart { id, name: str_of(&i["tool"]), args: i["arguments"].clone() }]
|
||||
}
|
||||
(Some("mcp_tool_call"), true) => {
|
||||
let failed = i["status"] == "failed" || !i["error"].is_null();
|
||||
let ev = if failed {
|
||||
let msg = match &i["error"] {
|
||||
Value::Null => content_text(&i["result"]["content"]),
|
||||
e => error_text(e, "tool failed"),
|
||||
};
|
||||
Event::ToolEnd { id, result: None, error: Some(msg) }
|
||||
} else {
|
||||
Event::ToolEnd { id, result: Some(i["result"].clone()), error: None }
|
||||
};
|
||||
vec![ev]
|
||||
}
|
||||
(Some("command_execution"), false) => {
|
||||
vec![Event::ToolStart { id, name: "command".into(), args: json!({ "command": i["command"] }) }]
|
||||
}
|
||||
(Some("command_execution"), true) => {
|
||||
let ok = i["exit_code"].as_i64() == Some(0);
|
||||
let out = str_of(&i["aggregated_output"]);
|
||||
vec![if ok {
|
||||
Event::ToolEnd { id, result: Some(json!(out)), error: None }
|
||||
} else {
|
||||
Event::ToolEnd { id, result: None, error: Some(out) }
|
||||
}]
|
||||
}
|
||||
(Some("error"), true) => vec![Event::Error(error_text(i, "codex error"))],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn text(i: &Value, thinking: bool) -> Vec<Event> {
|
||||
let Some(t) = i["text"].as_str().filter(|t| !t.is_empty()) else { return vec![] };
|
||||
vec![if thinking { Event::Thinking(t.into()) } else { Event::Text(t.into()) }]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use process::{detect, start};
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
use process::build_args;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod process {
|
||||
use super::*;
|
||||
use super::super::events::{Turn, TurnConfig};
|
||||
use std::{ffi::OsString, io::{BufRead, BufReader, Read}, path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio}, sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc::Sender},
|
||||
thread, time::{Duration, Instant}};
|
||||
|
||||
/// Keys that would make the CLI bill an API account instead of using its own login; the chat
|
||||
/// must run on the user's CLI login (spec, Security), so they are never inherited.
|
||||
const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
|
||||
pub(super) fn clean_env(cmd: &mut Command) -> &mut Command {
|
||||
for k in CREDENTIAL_ENV { cmd.env_remove(k); }
|
||||
crate::chat::path::apply(cmd)
|
||||
}
|
||||
|
||||
pub fn detect(binary: Option<&Path>) -> Option<(PathBuf, String)> {
|
||||
let path = match binary {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => crate::chat::path::which("codex")?,
|
||||
};
|
||||
Some(path).and_then(|path| {
|
||||
let output = clean_env(&mut Command::new(&path)).arg("--version").output().ok()?;
|
||||
if !output.status.success() { return None; }
|
||||
let version = String::from_utf8(output.stdout).ok()?.trim().to_owned();
|
||||
if version.is_empty() { return None; }
|
||||
Some((path, version))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_args(cfg: &TurnConfig, prompt: &str) -> Vec<OsString> {
|
||||
let mut args: Vec<OsString> = vec!["exec".into(), "--json".into(),
|
||||
"--skip-git-repo-check".into(), "-C".into(), cfg.project_dir.as_os_str().into(),
|
||||
"-c".into(), format!("mcp_servers.papercraft.url={}", json!(cfg.mcp_url)).into(),
|
||||
// `exec` never asks, so a tool that needs approval is refused: pre-approve ours only.
|
||||
"-c".into(), "mcp_servers.papercraft.default_tools_approval_mode=\"approve\"".into()];
|
||||
if let Some(model) = &cfg.model { args.extend(["-m".into(), model.into()]); }
|
||||
if let Some(session) = &cfg.session {
|
||||
args.extend(["resume".into(), "--".into(), session.into(), prompt.into()]);
|
||||
} else {
|
||||
let prompt = if cfg.system.is_empty() { prompt.to_owned() }
|
||||
else { format!("{}\n\n{prompt}", cfg.system) };
|
||||
args.extend(["--".into(), prompt.into()]);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
struct ProcessTurn {
|
||||
child: Arc<Mutex<Child>>,
|
||||
running: Arc<AtomicBool>,
|
||||
/// Set by `stop`: the exit that follows is the user's, not a failure.
|
||||
stopped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Turn for ProcessTurn {
|
||||
fn stop(&mut self) {
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const POLL: Duration = Duration::from_millis(20);
|
||||
{
|
||||
let mut child = self.child.lock().unwrap();
|
||||
if !matches!(child.try_wait(), Ok(None)) { return; }
|
||||
self.stopped.store(true, Ordering::SeqCst);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// The child is unreaped while this lock is held, so its PID cannot be reused.
|
||||
unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGINT); }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{ let _ = child.kill(); }
|
||||
}
|
||||
// Off the UI thread: kill and reap if the child is still running after the grace period.
|
||||
// `running` stays set until the reader thread has sent the turn's last event.
|
||||
let shared = self.child.clone();
|
||||
thread::spawn(move || {
|
||||
let deadline = Instant::now() + STOP_TIMEOUT;
|
||||
loop {
|
||||
{
|
||||
let mut child = shared.lock().unwrap();
|
||||
if !matches!(child.try_wait(), Ok(None)) { break; }
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool { self.running.load(Ordering::Acquire) }
|
||||
}
|
||||
|
||||
pub fn start(cfg: &TurnConfig, prompt: &str, tx: Sender<Event>) -> Result<Box<dyn Turn>, String> {
|
||||
let bin = cfg.binary.clone().or_else(|| crate::chat::path::which("codex")).ok_or("Codex (`codex`) was not found")?;
|
||||
let mut command = Command::new(bin);
|
||||
clean_env(&mut command).args(build_args(cfg, prompt)).current_dir(&cfg.project_dir);
|
||||
spawn_turn(command, tx)
|
||||
}
|
||||
|
||||
pub(super) fn spawn_turn(mut command: Command, tx: Sender<Event>) -> Result<Box<dyn Turn>, String> {
|
||||
let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped())
|
||||
.spawn().map_err(|e| format!("Could not start Codex: {e}"))?;
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let turn = ProcessTurn { child: child.clone(), running: running.clone(), stopped: stopped.clone() };
|
||||
thread::spawn(move || {
|
||||
let stderr = thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
stderr.read_to_string(&mut text).map(|_| text)
|
||||
});
|
||||
let mut parser = Parser::default();
|
||||
let mut terminal = None;
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(e) => { terminal = Some(Event::Error(format!("Codex stdout: {e}"))); break; }
|
||||
};
|
||||
let previous = parser.thread.clone();
|
||||
let events = parser.parse_line(&line);
|
||||
if parser.thread != previous {
|
||||
if let Some(id) = &parser.thread { let _ = tx.send(Event::Session(id.clone())); }
|
||||
}
|
||||
for event in events {
|
||||
if matches!(event, Event::TurnEnd { .. } | Event::Error(_)) {
|
||||
terminal = Some(event);
|
||||
} else { let _ = tx.send(event); }
|
||||
}
|
||||
}
|
||||
let status = loop {
|
||||
let result = child.lock().unwrap().try_wait();
|
||||
match result {
|
||||
Ok(None) => thread::sleep(Duration::from_millis(20)),
|
||||
result => break result,
|
||||
}
|
||||
};
|
||||
let stderr = stderr.join().ok().and_then(Result::ok).unwrap_or_default();
|
||||
let end = match status {
|
||||
_ if stopped.load(Ordering::SeqCst) && !matches!(terminal, Some(Event::TurnEnd { .. })) =>
|
||||
Event::TurnEnd { usage: None, session: parser.thread.clone(), stop_reason: Some("stopped".into()) },
|
||||
Ok(Some(status)) if status.success() => terminal.unwrap_or_else(||
|
||||
Event::Error("Codex exited without a turn result".into())),
|
||||
Ok(Some(status)) => Event::Error(format!("Codex exited with {status}: {}", stderr.trim())),
|
||||
Err(e) => Event::Error(format!("Could not wait for Codex: {e}")),
|
||||
Ok(None) => unreachable!(),
|
||||
};
|
||||
// Send first: the app treats "not running" as "everything is in the channel".
|
||||
let _ = tx.send(end);
|
||||
running.store(false, Ordering::Release);
|
||||
});
|
||||
Ok(Box::new(turn))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn arguments_initial_and_resume() {
|
||||
let mut cfg = super::super::events::TurnConfig {
|
||||
project_dir: "/tmp/project space".into(),
|
||||
mcp_url: "http://127.0.0.1:29980/mcp".into(),
|
||||
system: "PaperCraft instructions".into(),
|
||||
model: Some("test-model".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let args = build_args(&cfg, "-prompt");
|
||||
assert_eq!(args, vec!["exec", "--json", "--skip-git-repo-check", "-C",
|
||||
"/tmp/project space", "-c", "mcp_servers.papercraft.url=\"http://127.0.0.1:29980/mcp\"",
|
||||
"-c", "mcp_servers.papercraft.default_tools_approval_mode=\"approve\"",
|
||||
"-m", "test-model", "--", "PaperCraft instructions\n\n-prompt"]);
|
||||
cfg.session = Some("session-id".into());
|
||||
let args = build_args(&cfg, "next");
|
||||
assert_eq!(&args[args.len() - 4..], ["resume", "--", "session-id", "next"].map(std::ffi::OsString::from));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn credentials_are_not_inherited() {
|
||||
let mut c = std::process::Command::new("x");
|
||||
process::clean_env(&mut c);
|
||||
let removed: Vec<_> = c.get_envs().filter(|(_, v)| v.is_none()).map(|(k, _)| k.to_string_lossy().into_owned()).collect();
|
||||
for k in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"] {
|
||||
assert!(removed.iter().any(|r| r == k), "{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_interrupts_and_reaps() {
|
||||
let mut cmd = std::process::Command::new("sleep");
|
||||
cmd.arg("30");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut turn = process::spawn_turn(cmd, tx).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
turn.stop();
|
||||
assert!(t.elapsed() < std::time::Duration::from_millis(200), "stop blocked for {:?}", t.elapsed());
|
||||
// A stop is the user's choice: it ends the turn, it is not an error.
|
||||
assert!(matches!(rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "stopped"));
|
||||
turn.stop(); // already reaped: no-op
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn codex_integration() {
|
||||
if std::env::var("PAPERCRAFT_TEST_CODEX").as_deref() != Ok("1") || detect(None).is_none() {
|
||||
return;
|
||||
}
|
||||
let cfg = super::super::events::TurnConfig {
|
||||
project_dir: std::env::temp_dir(),
|
||||
mcp_url: "http://127.0.0.1:9/mcp".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut turn = start(&cfg, "Reply with exactly: hello. Do not use tools.", tx).unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(90);
|
||||
let mut session = false;
|
||||
let mut text = false;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok(Event::Session(_)) => session = true,
|
||||
Ok(Event::Text(_)) => text = true,
|
||||
Ok(Event::TurnEnd { .. }) => break,
|
||||
Ok(Event::Error(e)) => panic!("{e}"),
|
||||
Ok(_) => {},
|
||||
Err(e) => { turn.stop(); panic!("Codex timed out: {e}"); }
|
||||
}
|
||||
}
|
||||
assert!(session && text);
|
||||
assert!(!turn.is_running());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_events_exit_and_stop() {
|
||||
use std::time::Duration;
|
||||
fn launch(body: &str, tx: std::sync::mpsc::Sender<Event>) -> Box<dyn super::super::events::Turn> {
|
||||
let mut command = std::process::Command::new("/bin/sh");
|
||||
command.args(["-c", body]);
|
||||
process::spawn_turn(command, tx).unwrap()
|
||||
}
|
||||
for (body, fails) in [
|
||||
(format!("printf '%s\\n' '{}'", HI.trim().replace('\n', "' '")), false),
|
||||
("printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"early\"}'; echo diagnostic >&2; exit 7".into(), true),
|
||||
] {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let turn = launch(&body, tx);
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Event::Session(_)));
|
||||
let events: Vec<_> = rx.iter().collect();
|
||||
if fails {
|
||||
assert!(matches!(events.last(), Some(Event::Error(e)) if e.contains("diagnostic")));
|
||||
} else {
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Text(_))));
|
||||
assert!(matches!(events.last(), Some(Event::TurnEnd { .. })));
|
||||
}
|
||||
assert!(!turn.is_running());
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut turn = launch("trap '' INT; printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"stop\"}'; while :; do :; done", tx);
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Event::Session(_)));
|
||||
assert!(turn.is_running());
|
||||
let before = std::time::Instant::now();
|
||||
turn.stop();
|
||||
assert!(before.elapsed() < Duration::from_secs(4));
|
||||
// The user's stop ends the turn, it is not an error; "not running" comes after that event.
|
||||
assert!(matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Event::TurnEnd { stop_reason: Some(r), .. } if r == "stopped"));
|
||||
let t = std::time::Instant::now();
|
||||
while turn.is_running() && t.elapsed() < Duration::from_secs(5) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(!turn.is_running());
|
||||
}
|
||||
|
||||
// Real recordings from codex exec --json (--ephemeral, scratch dir, own PaperCraft instance
|
||||
// with no project open). codex_synthetic.jsonl is SYNTHETIC, written from the documented event
|
||||
// shapes to cover reasoning, command_execution, error and turn.failed.
|
||||
const HI: &str = include_str!("../../tests/fixtures/chat/codex_hi.jsonl");
|
||||
const TOOL: &str = include_str!("../../tests/fixtures/chat/codex_tool.jsonl");
|
||||
const SYNTH: &str = include_str!("../../tests/fixtures/chat/codex_synthetic.jsonl");
|
||||
|
||||
fn all(f: &str) -> Vec<Event> {
|
||||
let mut p = Parser::default();
|
||||
f.lines().flat_map(|l| p.parse_line(l)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hi() {
|
||||
let ev = all(HI);
|
||||
assert_eq!(ev[0], Event::Text("Hi there, friend!".into()));
|
||||
let Event::TurnEnd { usage: Some(u), session: Some(s), .. } = &ev[1] else { panic!("{ev:?}") };
|
||||
assert_eq!((u.input, u.output, u.cache_read, u.cache_write), (26064 - 12288, 9, 12288, 0));
|
||||
assert_eq!(s, "01a16076-15a4-71c0-8cfa-df664e87bdd2");
|
||||
assert_eq!(ev.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_run() {
|
||||
let ev = all(TOOL);
|
||||
assert_eq!(ev.len(), 5, "{ev:?}");
|
||||
assert!(matches!(&ev[0], Event::Text(_)));
|
||||
let Event::ToolStart { id, name, args } = &ev[1] else { panic!() };
|
||||
assert_eq!((id.as_str(), name.as_str(), args), ("item_1", "get_basic_info", &json!({})));
|
||||
let Event::ToolEnd { id: e, result: None, error: Some(m) } = &ev[2] else { panic!("{:?}", ev[2]) };
|
||||
assert_eq!(e, id);
|
||||
assert!(m.starts_with("No project is open"));
|
||||
assert_eq!(ev[3], Event::Text("No file open in PaperCraft.".into()));
|
||||
assert!(matches!(&ev[4], Event::TurnEnd { session: Some(_), .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic() {
|
||||
let ev = all(SYNTH);
|
||||
assert_eq!(ev[0], Event::Thinking("Plan the frame first.".into()));
|
||||
assert!(matches!(&ev[1], Event::ToolStart { name, .. } if name == "command"));
|
||||
assert!(matches!(&ev[2], Event::ToolEnd { result: Some(r), .. } if r == "a.txt\n"));
|
||||
assert!(matches!(&ev[3], Event::ToolStart { name, args, .. } if name == "create_artboard" && args["name"] == "Home"));
|
||||
assert!(matches!(&ev[4], Event::ToolEnd { error: Some(e), .. } if e == "denied"));
|
||||
assert_eq!(ev[5], Event::Error("stream disconnected, retrying".into()));
|
||||
assert_eq!(ev[6], Event::Error("rate limited".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! API endpoints for the chat API backend: a name, a wire format, a base URL and a model. Keys
|
||||
//! live in the OS keychain under the endpoint's base URL, never in settings files.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CLAUDE_HOST: &str = "api.anthropic.com";
|
||||
pub const DEFAULT_ENDPOINT_ID: &str = "anthropic";
|
||||
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
|
||||
pub const DEFAULT_MODEL: &str = "claude-opus-5-5";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApiFormat {
|
||||
/// Messages API: `POST {base}/v1/messages`, `x-api-key`.
|
||||
#[default]
|
||||
Anthropic,
|
||||
/// Chat Completions: `POST {base}/chat/completions`, `Authorization: Bearer`.
|
||||
OpenAi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Endpoint {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub format: ApiFormat,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// The built-in `Anthropic` endpoint, so the first-run path stays one key away.
|
||||
impl Default for Endpoint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: DEFAULT_ENDPOINT_ID.into(),
|
||||
name: "Anthropic".into(),
|
||||
format: ApiFormat::Anthropic,
|
||||
base_url: DEFAULT_BASE_URL.into(),
|
||||
model: DEFAULT_MODEL.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// A blank endpoint for the settings "Add" button.
|
||||
pub fn blank() -> Self {
|
||||
Self { id: format!("ep-{:08x}", fastrand::u32(..)), name: "New endpoint".into(), format: ApiFormat::OpenAi, base_url: String::new(), model: String::new() }
|
||||
}
|
||||
|
||||
/// Claude-only request fields (effort, thinking display, fallbacks, eager input streaming,
|
||||
/// cache breakpoints) are sent only to this host.
|
||||
pub fn is_claude_host(&self) -> bool {
|
||||
host(&self.base_url).as_deref() == Some(CLAUDE_HOST)
|
||||
}
|
||||
|
||||
/// A chat can use it: URL and model are set.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self.base_url.trim().is_empty() && !self.model.trim().is_empty()
|
||||
}
|
||||
|
||||
/// `anthropic.com` needs a key; local and third-party servers may not.
|
||||
pub fn needs_key(&self) -> bool {
|
||||
self.is_claude_host()
|
||||
}
|
||||
|
||||
pub fn url(&self) -> String {
|
||||
let base = self.base_url.trim().trim_end_matches('/');
|
||||
match self.format {
|
||||
ApiFormat::Anthropic => format!("{base}/v1/messages"),
|
||||
ApiFormat::OpenAi => format!("{base}/chat/completions"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trimmed, no trailing slash, scheme and host lowercased (the path keeps its case).
|
||||
pub fn normalize_url(url: &str) -> String {
|
||||
let u = url.trim().trim_end_matches('/');
|
||||
let Some((scheme, rest)) = u.split_once("://") else { return u.to_lowercase() };
|
||||
let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
|
||||
let sep = if path.is_empty() { "" } else { "/" };
|
||||
format!("{}://{}{sep}{path}", scheme.to_lowercase(), authority.to_lowercase())
|
||||
}
|
||||
|
||||
/// Lowercase host without userinfo or port.
|
||||
pub fn host(url: &str) -> Option<String> {
|
||||
let rest = url.trim().split_once("://")?.1;
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let hostport = authority.rsplit('@').next()?;
|
||||
let host = hostport.split(':').next()?.to_lowercase();
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
|
||||
/// Keychain user name for an endpoint's key.
|
||||
pub fn key_user(base_url: &str) -> String {
|
||||
format!("api-key:{}", normalize_url(base_url))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use keys::{delete_key, get_key, set_key};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod keys {
|
||||
use super::*;
|
||||
|
||||
const KEY_SERVICE: &str = "PaperCraft";
|
||||
/// The entry used before endpoints existed; still read for `api.anthropic.com`.
|
||||
const LEGACY_KEY_USER: &str = "anthropic-api-key";
|
||||
const KEY_ENV: &str = "ANTHROPIC_API_KEY";
|
||||
|
||||
fn entry(user: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(KEY_SERVICE, user).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read(user: &str) -> Option<String> {
|
||||
entry(user).ok()?.get_password().ok().map(|k| k.trim().to_string()).filter(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
fn remove(user: &str) -> Result<(), String> {
|
||||
match entry(user)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored key, for `api.anthropic.com` also the legacy entry, then `ANTHROPIC_API_KEY`.
|
||||
/// Never log the result.
|
||||
pub fn get_key(ep: &Endpoint) -> Option<String> {
|
||||
let stored = read(&key_user(&ep.base_url));
|
||||
if !ep.is_claude_host() {
|
||||
return stored;
|
||||
}
|
||||
stored.or_else(|| read(LEGACY_KEY_USER)).or_else(|| std::env::var(KEY_ENV).ok().map(|k| k.trim().to_string()).filter(|k| !k.is_empty()))
|
||||
}
|
||||
|
||||
/// Saves under the endpoint's base URL; a legacy entry is dropped (the key moved).
|
||||
pub fn set_key(ep: &Endpoint, key: &str) -> Result<(), String> {
|
||||
entry(&key_user(&ep.base_url))?.set_password(key.trim()).map_err(|e| e.to_string())?;
|
||||
if ep.is_claude_host() {
|
||||
let _ = remove(LEGACY_KEY_USER);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_key(ep: &Endpoint) -> Result<(), String> {
|
||||
remove(&key_user(&ep.base_url))?;
|
||||
if ep.is_claude_host() {
|
||||
remove(LEGACY_KEY_USER)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `(input, output)` USD per million tokens for models in the Claude price table; None for any
|
||||
/// other model (the cost line then shows tokens only).
|
||||
pub fn price(model: &str) -> Option<(f64, f64)> {
|
||||
if !model.starts_with("claude") {
|
||||
return None;
|
||||
}
|
||||
match model {
|
||||
m if m.contains("haiku") => Some((0.10, 0.50)),
|
||||
m if m.contains("sonnet") => Some((2.0, 10.0)),
|
||||
m if m.contains("opus") => Some((4.0, 20.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const TOKENS_PER_MTOK: f64 = 1_000_000.0;
|
||||
const CACHE_READ_FACTOR: f64 = 0.1;
|
||||
const CACHE_WRITE_FACTOR: f64 = 1.25;
|
||||
|
||||
/// Estimated USD for `u` on `model` (cache reads at 10% of input, writes at 125%); None for a
|
||||
/// model without a price.
|
||||
pub fn cost(model: &str, u: &super::Usage) -> Option<f64> {
|
||||
let (pin, pout) = price(model)?;
|
||||
let input = u.input as f64 + u.cache_read as f64 * CACHE_READ_FACTOR + u.cache_write as f64 * CACHE_WRITE_FACTOR;
|
||||
Some((input * pin + u.output as f64 * pout) / TOKENS_PER_MTOK)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_ignores_userinfo_port_and_lookalikes() {
|
||||
for (url, h) in [
|
||||
("https://api.anthropic.com", Some("api.anthropic.com")),
|
||||
("HTTPS://API.Anthropic.com/v1/", Some("api.anthropic.com")),
|
||||
("https://api.anthropic.com:443/x", Some("api.anthropic.com")),
|
||||
("https://api.anthropic.com@evil.example/x", Some("evil.example")),
|
||||
("https://api.anthropic.com.evil.example", Some("api.anthropic.com.evil.example")),
|
||||
("http://127.0.0.1:11434/v1", Some("127.0.0.1")),
|
||||
("not a url", None),
|
||||
] {
|
||||
assert_eq!(host(url).as_deref(), h, "{url}");
|
||||
}
|
||||
let claude = |u: &str| Endpoint { base_url: u.into(), ..Default::default() }.is_claude_host();
|
||||
assert!(claude("https://api.anthropic.com/") && !claude("https://api.anthropic.com.evil.example") && !claude("http://127.0.0.1:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyring_user_is_the_normalized_base_url() {
|
||||
assert_eq!(key_user("https://api.anthropic.com"), "api-key:https://api.anthropic.com");
|
||||
assert_eq!(key_user(" HTTPS://API.Anthropic.com/ "), "api-key:https://api.anthropic.com");
|
||||
assert_eq!(key_user("http://127.0.0.1:11434/v1/"), "api-key:http://127.0.0.1:11434/v1");
|
||||
assert_eq!(key_user("https://h.example/Path/X"), "api-key:https://h.example/Path/X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_per_format() {
|
||||
let mut e = Endpoint { base_url: "https://h.example/anthropic/".into(), ..Default::default() };
|
||||
assert_eq!(e.url(), "https://h.example/anthropic/v1/messages");
|
||||
e.format = ApiFormat::OpenAi;
|
||||
e.base_url = "http://127.0.0.1:11434/v1".into();
|
||||
assert_eq!(e.url(), "http://127.0.0.1:11434/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_only_for_claude_models() {
|
||||
let u = crate::chat::Usage { input: 1_000_000, output: 1_000_000, cache_read: 1_000_000, cache_write: 0, cost_usd: None };
|
||||
assert!((cost("claude-opus-5-5", &u).unwrap() - 24.4).abs() < 1e-9);
|
||||
assert!((cost("claude-sonnet-5-5", &u).unwrap() - 12.2).abs() < 1e-9);
|
||||
assert!((cost("claude-haiku-5-5", &u).unwrap() - 0.61).abs() < 1e-9);
|
||||
assert_eq!(cost("MiniMax-M3.1-Flash", &u), None);
|
||||
assert_eq!(cost("llama3", &u), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The one event stream every chat backend (Claude Code, Codex, API endpoints) is turned into.
|
||||
|
||||
use super::Usage;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Event {
|
||||
Text(String),
|
||||
Thinking(String),
|
||||
ToolStart { id: String, name: String, args: Value },
|
||||
/// `result` on success, `error` on failure; exactly one is set.
|
||||
ToolEnd { id: String, result: Option<Value>, error: Option<String> },
|
||||
TurnEnd { usage: Option<Usage>, session: Option<String>, stop_reason: Option<String> },
|
||||
/// The backend's session id, as soon as it is known (so a stopped turn can still resume).
|
||||
Session(String),
|
||||
/// API backend: a destructive tool waits for `ApiTurn::approve(id, ..)` before it runs.
|
||||
NeedsApproval { id: String, name: String, args: Value },
|
||||
Error(String),
|
||||
/// Tokens in the context at the backend's latest model call (input and cache), not summed
|
||||
/// over the turn: the header meter.
|
||||
Context(u64),
|
||||
}
|
||||
|
||||
/// Sends a tool call to the UI thread (the app's MCP dispatch), so runs, undo and the agent log
|
||||
/// behave exactly as for an MCP request. Returns false when the app no longer takes calls.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDispatch(pub std::sync::Arc<dyn Fn(crate::mcp::Call) -> bool + Send + Sync>);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl std::fmt::Debug for ToolDispatch {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ToolDispatch")
|
||||
}
|
||||
}
|
||||
|
||||
/// An image the user attached to a message (API backend: sent as an image block).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ImageAttachment {
|
||||
/// `image/png`, `image/jpeg`, ...
|
||||
pub mime: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What a turn needs from the app, the same for every backend.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TurnConfig {
|
||||
/// The project folder (working directory for CLI agents).
|
||||
pub project_dir: std::path::PathBuf,
|
||||
/// The app's MCP endpoint, `http://127.0.0.1:<port>/mcp`.
|
||||
pub mcp_url: String,
|
||||
/// PaperCraft skill text and project context, appended to the system prompt.
|
||||
pub system: String,
|
||||
/// Model id; None = the backend's default (API: the endpoint's model).
|
||||
pub model: Option<String>,
|
||||
/// `low` | `medium` | `high`; None = the backend's default.
|
||||
pub effort: Option<String>,
|
||||
/// Resume this backend session.
|
||||
pub session: Option<String>,
|
||||
/// Override for the CLI binary path.
|
||||
pub binary: Option<std::path::PathBuf>,
|
||||
/// API backend: where tool calls run (see [`ToolDispatch`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub tools: Option<ToolDispatch>,
|
||||
/// API backend: the raw messages array, `chats/<id>.api.json`.
|
||||
pub api_history: Option<std::path::PathBuf>,
|
||||
/// API backend: images attached to the new user message.
|
||||
pub images: Vec<ImageAttachment>,
|
||||
/// API backend: the endpoint (format, base URL, model).
|
||||
pub endpoint: super::endpoint::Endpoint,
|
||||
/// API backend: base URL override for the request only (tests); Claude-only fields still follow
|
||||
/// `endpoint`. Anthropic format also reads `PAPERCRAFT_ANTHROPIC_BASE_URL`.
|
||||
pub api_base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A running turn. Events arrive on the receiver given to `start`; the last one is `TurnEnd`
|
||||
/// or `Error`.
|
||||
pub trait Turn: Send {
|
||||
/// Ask the backend to stop; it must end within 3 s (kill after that).
|
||||
fn stop(&mut self);
|
||||
fn is_running(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Text of a tool result: a plain string, or the `text` of each content block joined.
|
||||
pub(crate) fn content_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(a) => a.iter().filter_map(|b| b["text"].as_str()).collect::<Vec<_>>().join("\n"),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn str_of(v: &Value) -> String {
|
||||
v.as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Chat: a conversation with a coding agent (Claude Code, Codex or an API endpoint). Each chat
|
||||
//! is one JSON file under the project's `chats/` folder, see [`store`].
|
||||
|
||||
pub mod agents;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod acp;
|
||||
pub mod anthropic;
|
||||
pub mod claude_code;
|
||||
pub mod codex;
|
||||
pub mod endpoint;
|
||||
pub mod events;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod openai;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod path;
|
||||
pub mod store;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod tools;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where images for CLI backends and the system prompt file are saved, under the project folder.
|
||||
pub const TMP_DIR: &str = ".papercraft-chat-tmp";
|
||||
/// Each agent's own setup page, linked from a missing agent's row.
|
||||
pub const CLAUDE_DOCS: &str = "https://docs.anthropic.com/en/docs/claude-code/setup";
|
||||
pub const CODEX_DOCS: &str = "https://github.com/openai/codex";
|
||||
const ID_MAX: usize = 64;
|
||||
|
||||
/// Chat ids become file and folder names, so only ascii letters, digits, `-` and `_` pass: no
|
||||
/// path separators, no `.`.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= ID_MAX && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendKind {
|
||||
#[default]
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
/// An API endpoint (any format); the name is kept for saved chats.
|
||||
Anthropic,
|
||||
/// Any agent that speaks the Agent Client Protocol; [`Backend::agent`] says which.
|
||||
Acp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Backend {
|
||||
pub kind: BackendKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// API chats: the endpoint's id; None = the default endpoint.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// The CLI's session id, so a follow-up message resumes the same session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cli_session: Option<String>,
|
||||
/// `Acp` chats: the registry id (`agents::AGENTS`) or custom agent id.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Usage {
|
||||
#[serde(default)]
|
||||
pub input: u64,
|
||||
#[serde(default)]
|
||||
pub output: u64,
|
||||
#[serde(default)]
|
||||
pub cache_read: u64,
|
||||
#[serde(default)]
|
||||
pub cache_write: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// Milliseconds the tool took.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
#[default]
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
#[serde(default)]
|
||||
pub role: Role,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ToolCall>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub run_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<Usage>,
|
||||
/// Tokens in the context at the turn's last model call (input and cache), for the header meter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<u64>,
|
||||
/// Unix milliseconds.
|
||||
#[serde(default)]
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Chat {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// Unix milliseconds.
|
||||
#[serde(default)]
|
||||
pub created: u64,
|
||||
/// Unix milliseconds; `list` sorts on it.
|
||||
#[serde(default)]
|
||||
pub updated: u64,
|
||||
#[serde(default)]
|
||||
pub backend: Backend,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<Message>,
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
//! Chat Completions streaming (OpenAI-compatible endpoints): an SSE accumulator that turns the
|
||||
//! response into [`Event`]s, and the tool loop. Same event stream, approvals, schema validation,
|
||||
//! round cap and persist-then-emit rule as `anthropic`. No provider-specific request fields.
|
||||
//!
|
||||
//! History (`chats/<id>.api.json`) is the OpenAI `messages` array without the system message:
|
||||
//! `user`, `assistant` (with `tool_calls`) and `tool` messages. An OpenAI `tool` message carries
|
||||
//! text only, so a tool result with an image goes back as the text `[image omitted]` and the image
|
||||
//! follows as an extra `user` message right after the tool messages of that round.
|
||||
|
||||
use super::events::Event;
|
||||
use super::{Usage, endpoint};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One tool call of a finished reply.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolUse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The parsed arguments, or why they could not be parsed.
|
||||
pub input: Result<Value, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Call {
|
||||
id: String,
|
||||
name: String,
|
||||
args: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ChatParser {
|
||||
buf: String,
|
||||
text: String,
|
||||
calls: BTreeMap<usize, Call>,
|
||||
uses: Vec<ToolUse>,
|
||||
ended: bool,
|
||||
finish: Option<String>,
|
||||
usage: Option<Usage>,
|
||||
/// Prompt tokens of the call (input and cache), for the context meter.
|
||||
prompt: u64,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl ChatParser {
|
||||
/// Feed any chunk of the response body; incomplete lines wait for the next call.
|
||||
pub fn push(&mut self, chunk: &str) -> Vec<Event> {
|
||||
self.buf.push_str(chunk);
|
||||
let mut out = vec![];
|
||||
while let Some(nl) = self.buf.find('\n') {
|
||||
let line: String = self.buf.drain(..=nl).collect();
|
||||
let Some(data) = line.trim_end().strip_prefix("data:") else { continue };
|
||||
let data = data.trim();
|
||||
if data == "[DONE]" {
|
||||
self.done = true;
|
||||
self.end_round(&mut out);
|
||||
} else if let Ok(v) = serde_json::from_str::<Value>(data) {
|
||||
self.chunk(&v, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `[DONE]` seen: the reply is complete.
|
||||
pub fn done(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn finish_reason(&self) -> Option<&str> {
|
||||
self.finish.as_deref()
|
||||
}
|
||||
|
||||
pub fn tool_uses(&self) -> &[ToolUse] {
|
||||
&self.uses
|
||||
}
|
||||
|
||||
/// None when the server sent no usage.
|
||||
pub fn usage(&self) -> Option<&Usage> {
|
||||
self.usage.as_ref()
|
||||
}
|
||||
|
||||
pub fn prompt_tokens(&self) -> u64 {
|
||||
self.prompt
|
||||
}
|
||||
|
||||
fn chunk(&mut self, v: &Value, out: &mut Vec<Event>) {
|
||||
if v["error"].is_object() || v["error"].is_string() {
|
||||
let e = &v["error"];
|
||||
let msg = e["message"].as_str().or_else(|| e.as_str()).unwrap_or("the server reported an error");
|
||||
return out.push(Event::Error(msg.to_string()));
|
||||
}
|
||||
if v["usage"].is_object() {
|
||||
self.merge_usage(&v["usage"]);
|
||||
}
|
||||
let choice = &v["choices"][0];
|
||||
let delta = &choice["delta"];
|
||||
if let Some(s) = delta["content"].as_str().filter(|s| !s.is_empty()) {
|
||||
self.text.push_str(s);
|
||||
out.push(Event::Text(s.to_string()));
|
||||
}
|
||||
// Reasoning text, when the server streams it under either common name.
|
||||
if let Some(s) = ["reasoning_content", "reasoning"].iter().find_map(|k| delta[*k].as_str().filter(|s| !s.is_empty())) {
|
||||
out.push(Event::Thinking(s.to_string()));
|
||||
}
|
||||
for (pos, tc) in delta["tool_calls"].as_array().into_iter().flatten().enumerate() {
|
||||
let idx = tc["index"].as_u64().map_or(pos, |i| i as usize);
|
||||
let c = self.calls.entry(idx).or_default();
|
||||
if c.id.is_empty() {
|
||||
c.id = tc["id"].as_str().unwrap_or_default().to_string();
|
||||
}
|
||||
let f = &tc["function"];
|
||||
if c.name.is_empty() {
|
||||
c.name = f["name"].as_str().unwrap_or_default().to_string();
|
||||
}
|
||||
match &f["arguments"] {
|
||||
Value::String(s) => c.args.push_str(s),
|
||||
Value::Null => {}
|
||||
other => c.args.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
if let Some(r) = choice["finish_reason"].as_str() {
|
||||
self.finish = Some(r.to_string());
|
||||
self.end_round(out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the accumulated tool calls once and announces them.
|
||||
fn end_round(&mut self, out: &mut Vec<Event>) {
|
||||
if self.ended {
|
||||
return;
|
||||
}
|
||||
self.ended = true;
|
||||
self.finish.get_or_insert_with(|| "stop".into());
|
||||
for c in std::mem::take(&mut self.calls).into_values() {
|
||||
let id = if c.id.is_empty() { format!("call_{:08x}", fastrand::u32(..)) } else { c.id };
|
||||
let input = if c.args.trim().is_empty() {
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
serde_json::from_str::<Value>(&c.args).map_err(|e| format!("invalid tool arguments JSON: {e}"))
|
||||
};
|
||||
out.push(Event::ToolStart { id: id.clone(), name: c.name.clone(), args: input.clone().unwrap_or_else(|_| json!({})) });
|
||||
self.uses.push(ToolUse { id, name: c.name, input });
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage can arrive in the last chunk only, or in every chunk; the latest wins.
|
||||
fn merge_usage(&mut self, u: &Value) {
|
||||
let n = |v: &Value| v.as_u64().unwrap_or(0);
|
||||
let prompt = n(&u["prompt_tokens"]);
|
||||
let cached = n(&u["prompt_tokens_details"]["cached_tokens"]);
|
||||
self.prompt = prompt;
|
||||
self.usage = Some(Usage { input: prompt.saturating_sub(cached), output: n(&u["completion_tokens"]), cache_read: cached, cache_write: 0, cost_usd: None });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use api::{build_body, build_headers, start, tools_json};
|
||||
|
||||
/// The Chat Completions backend: request building and the streaming tool loop.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod api {
|
||||
use super::*;
|
||||
use crate::chat::events::TurnConfig;
|
||||
use crate::chat::tools::{self, ApiTurn, Ctl, Halt, Sink, CONTINUE_NOTE, CUT_OFF, MAX_TOOL_ROUNDS, STOPPED_BY_USER};
|
||||
use crate::mcp::Out;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
const IMAGE_OMITTED: &str = "[image omitted]";
|
||||
const IMAGE_NOTE: &str = "Image result of the tool call(s) above (a tool message cannot carry an image):";
|
||||
|
||||
// ------------------------------------------------------------------ request
|
||||
|
||||
/// No key header for a keyless local server.
|
||||
pub fn build_headers(key: &str) -> Vec<(&'static str, String)> {
|
||||
let mut h = vec![("content-type", "application/json".to_string())];
|
||||
if !key.is_empty() {
|
||||
h.push(("authorization", format!("Bearer {key}")));
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The app's tools as `{"type":"function","function":{name,description,parameters}}`.
|
||||
pub fn tools_json() -> Vec<Value> {
|
||||
tools::tool_specs()
|
||||
.into_iter()
|
||||
.map(|t| json!({"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["inputSchema"]}}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Only the portable fields: no `tool_choice`, no max-token or provider-specific settings.
|
||||
pub fn build_body(cfg: &TurnConfig, tools: &[Value], messages: &[Value]) -> Value {
|
||||
let model = cfg.model.as_deref().unwrap_or(&cfg.endpoint.model);
|
||||
let mut all = vec![];
|
||||
if !cfg.system.is_empty() {
|
||||
all.push(json!({"role": "system", "content": cfg.system}));
|
||||
}
|
||||
all.extend(messages.iter().cloned());
|
||||
json!({
|
||||
"model": model,
|
||||
"stream": true,
|
||||
"stream_options": {"include_usage": true},
|
||||
"tools": tools,
|
||||
"messages": all,
|
||||
})
|
||||
}
|
||||
|
||||
fn data_uri(mime: &str, data: &[u8]) -> String {
|
||||
format!("data:{mime};base64,{}", STANDARD.encode(data))
|
||||
}
|
||||
|
||||
/// A plain string, or the images then the text when images are attached.
|
||||
fn user_content(prompt: &str, images: &[crate::chat::events::ImageAttachment]) -> Value {
|
||||
if images.is_empty() {
|
||||
return json!(prompt);
|
||||
}
|
||||
let mut parts: Vec<Value> = images.iter().map(|i| json!({"type": "image_url", "image_url": {"url": data_uri(&i.mime, &i.data)}})).collect();
|
||||
parts.push(json!({"type": "text", "text": prompt}));
|
||||
Value::Array(parts)
|
||||
}
|
||||
|
||||
/// The `tool` message content: the texts, with `[image omitted]` for each image.
|
||||
fn tool_text(r: &Result<Vec<Out>, String>) -> String {
|
||||
match r {
|
||||
Err(e) => format!("Error: {e}"),
|
||||
Ok(outs) => {
|
||||
let parts: Vec<&str> = outs
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Out::Text(t) => t.as_str(),
|
||||
Out::Image { .. } => IMAGE_OMITTED,
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() { "OK".into() } else { parts.join("\n") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(cfg: &TurnConfig, prompt: &str, tx: Sender<Event>) -> Result<ApiTurn, String> {
|
||||
let key = endpoint::get_key(&cfg.endpoint).unwrap_or_default();
|
||||
start_with_key(cfg, prompt, key, tx)
|
||||
}
|
||||
|
||||
pub(super) fn start_with_key(cfg: &TurnConfig, prompt: &str, key: String, tx: Sender<Event>) -> Result<ApiTurn, String> {
|
||||
let mut messages = tools::load_history(cfg.api_history.as_ref())?;
|
||||
messages.push(json!({"role": "user", "content": user_content(prompt, &cfg.images)}));
|
||||
let cfg = cfg.clone();
|
||||
ApiTurn::spawn("chat-openai", move |stop, approvals| {
|
||||
Worker { cfg, key, sink: Sink::new(tx, stop), messages, approvals }.run();
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ worker
|
||||
|
||||
struct Worker {
|
||||
cfg: TurnConfig,
|
||||
key: String,
|
||||
sink: Sink,
|
||||
messages: Vec<Value>,
|
||||
approvals: Receiver<(String, bool)>,
|
||||
}
|
||||
|
||||
struct Round {
|
||||
text: String,
|
||||
uses: Vec<ToolUse>,
|
||||
finish: Option<String>,
|
||||
usage: Option<Usage>,
|
||||
prompt: u64,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn run(mut self) {
|
||||
if let Err(e) = tools::block_on(self.turn()) {
|
||||
self.emit(Event::Error(e));
|
||||
}
|
||||
tools::persist(self.cfg.api_history.as_ref(), &self.messages);
|
||||
// Persist first: the app deletes or reopens the chat as soon as it sees the end.
|
||||
self.sink.flush();
|
||||
}
|
||||
|
||||
fn emit(&self, e: Event) {
|
||||
self.sink.emit(e);
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
tools::persist(self.cfg.api_history.as_ref(), &self.messages);
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
self.cfg.model.as_deref().unwrap_or(&self.cfg.endpoint.model)
|
||||
}
|
||||
|
||||
fn fail(&self, m: String) {
|
||||
self.emit(Event::Error(tools::redact(&m, &self.key)));
|
||||
}
|
||||
|
||||
/// `reported`: some call returned usage; otherwise the turn has no usage row.
|
||||
fn finish(&self, total: &mut Usage, reported: bool, stop_reason: &str) {
|
||||
let usage = reported.then(|| {
|
||||
total.cost_usd = endpoint::cost(self.model(), total);
|
||||
total.clone()
|
||||
});
|
||||
self.emit(Event::TurnEnd { usage, session: None, stop_reason: Some(stop_reason.into()) });
|
||||
}
|
||||
|
||||
/// The assistant message, then one `tool` message per call, then the images (if any) as a
|
||||
/// user message. `results` has one entry per call, in order.
|
||||
fn push_round(&mut self, round: &Round, results: &[Result<Vec<Out>, String>]) {
|
||||
if round.text.is_empty() && round.uses.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut msg = json!({"role": "assistant", "content": if round.text.is_empty() { Value::Null } else { json!(round.text) }});
|
||||
if !round.uses.is_empty() {
|
||||
msg["tool_calls"] = round
|
||||
.uses
|
||||
.iter()
|
||||
.map(|u| json!({"type": "function", "id": u.id, "function": {"name": u.name, "arguments": u.input.clone().unwrap_or_else(|_| json!({})).to_string()}}))
|
||||
.collect();
|
||||
}
|
||||
self.messages.push(msg);
|
||||
let mut images: Vec<Value> = vec![];
|
||||
for (u, r) in round.uses.iter().zip(results) {
|
||||
self.messages.push(json!({"role": "tool", "tool_call_id": u.id, "content": tool_text(r)}));
|
||||
for o in r.iter().flatten() {
|
||||
if let Out::Image { data, mime } = o {
|
||||
images.push(json!({"type": "image_url", "image_url": {"url": data_uri(mime, data)}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !images.is_empty() {
|
||||
images.insert(0, json!({"type": "text", "text": IMAGE_NOTE}));
|
||||
self.messages.push(json!({"role": "user", "content": images}));
|
||||
}
|
||||
}
|
||||
|
||||
async fn turn(&mut self) {
|
||||
let client = match tools::client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return self.emit(Event::Error(e)),
|
||||
};
|
||||
let url = match &self.cfg.api_base_url {
|
||||
Some(base) => format!("{}/chat/completions", base.trim_end_matches('/')),
|
||||
None => self.cfg.endpoint.url(),
|
||||
};
|
||||
let tools_body = tools_json();
|
||||
let specs = tools::tool_specs();
|
||||
let mut total = Usage::default();
|
||||
let (mut reported, mut rounds, mut continued) = (false, 0, false);
|
||||
loop {
|
||||
if self.sink.stopped() {
|
||||
return self.finish(&mut total, reported, "stopped");
|
||||
}
|
||||
let body = build_body(&self.cfg, &tools_body, &self.messages);
|
||||
let round = match self.stream(&client, &url, &body).await {
|
||||
Ok(r) => r,
|
||||
Err(Halt::Stopped) => return self.finish(&mut total, reported, "stopped"),
|
||||
Err(Halt::Reported) => return,
|
||||
Err(Halt::Fail(m)) => return self.fail(m),
|
||||
};
|
||||
if let Some(u) = &round.usage {
|
||||
reported = true;
|
||||
self.emit(Event::Context(round.prompt));
|
||||
total.input += u.input;
|
||||
total.output += u.output;
|
||||
total.cache_read += u.cache_read;
|
||||
}
|
||||
let finish = round.finish.as_deref().unwrap_or("stop");
|
||||
match finish {
|
||||
"content_filter" => return self.emit(Event::Error("The server's content filter blocked the reply.".into())),
|
||||
"length" if !continued => {
|
||||
continued = true;
|
||||
// Cut-off tool arguments are never run; answer them so the history stays valid.
|
||||
let results: Vec<_> = round.uses.iter().map(|_| Err(CUT_OFF.to_string())).collect();
|
||||
self.push_round(&round, &results);
|
||||
self.messages.push(json!({"role": "user", "content": CONTINUE_NOTE}));
|
||||
self.persist();
|
||||
}
|
||||
"length" => {
|
||||
let results: Vec<_> = round.uses.iter().map(|_| Err(CUT_OFF.to_string())).collect();
|
||||
self.push_round(&round, &results);
|
||||
self.persist();
|
||||
return self.finish(&mut total, reported, "length");
|
||||
}
|
||||
_ if !round.uses.is_empty() => {
|
||||
if rounds >= MAX_TOOL_ROUNDS {
|
||||
return self.emit(Event::Error(format!("Stopped after {MAX_TOOL_ROUNDS} tool rounds; send a message to continue.")));
|
||||
}
|
||||
// A stop mid-round still records what ran, so the next turn matches the document.
|
||||
let (results, stopped) = self.run_tools(&specs, &round.uses).await;
|
||||
self.push_round(&round, &results);
|
||||
rounds += 1;
|
||||
self.persist();
|
||||
if stopped {
|
||||
return self.finish(&mut total, reported, "stopped");
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.push_round(&round, &[]);
|
||||
self.persist();
|
||||
return self.finish(&mut total, reported, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request; forwards text, reasoning and tool starts as they stream.
|
||||
async fn stream(&self, client: &reqwest::Client, url: &str, body: &Value) -> Result<Round, Halt> {
|
||||
let label = format!("{} API", self.cfg.endpoint.name);
|
||||
let resp = tools::post(&self.sink.stop, client, url, build_headers(&self.key), body, &label).await?;
|
||||
let mut parser = ChatParser::default();
|
||||
let mut errored = false;
|
||||
tools::read_stream(&self.sink.stop, resp, |text| {
|
||||
for ev in parser.push(text) {
|
||||
if matches!(ev, Event::Error(_)) {
|
||||
errored = true;
|
||||
}
|
||||
self.emit(ev);
|
||||
}
|
||||
parser.done()
|
||||
})
|
||||
.await?;
|
||||
// Some servers close the stream after the last chunk without `[DONE]`.
|
||||
let _ = parser.push("\n");
|
||||
if parser.finish_reason().is_none() {
|
||||
return Err(if errored { Halt::Reported } else { Halt::Fail("The stream ended before the reply was complete.".into()) });
|
||||
}
|
||||
Ok(Round {
|
||||
text: parser.text().to_string(),
|
||||
uses: parser.tool_uses().to_vec(),
|
||||
finish: parser.finish_reason().map(str::to_string),
|
||||
usage: parser.usage().cloned(),
|
||||
prompt: parser.prompt_tokens(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the calls in order, one result each; once stopped, the rest (and the interrupted
|
||||
/// one) get an error result. The flag says whether a stop happened.
|
||||
async fn run_tools(&self, specs: &[Value], uses: &[ToolUse]) -> (Vec<Result<Vec<Out>, String>>, bool) {
|
||||
let emit = |e| self.emit(e);
|
||||
let ctl = Ctl { stop: &self.sink.stop, approvals: &self.approvals, dispatch: self.cfg.tools.as_ref(), emit: &emit };
|
||||
let (mut results, mut stopped) = (vec![], false);
|
||||
for u in uses {
|
||||
let r = match (&u.input, stopped) {
|
||||
(_, true) => Err(STOPPED_BY_USER.to_string()),
|
||||
(Err(e), false) => Err(format!("invalid arguments for `{}`: {e}", u.name)),
|
||||
(Ok(args), false) => ctl.run_one(specs, &u.id, &u.name, args).await.unwrap_or_else(|| {
|
||||
stopped = true;
|
||||
Err(STOPPED_BY_USER.to_string())
|
||||
}),
|
||||
};
|
||||
self.emit(Ctl::end_event(&u.id, &r));
|
||||
results.push(r);
|
||||
}
|
||||
(results, stopped)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::endpoint::{ApiFormat, Endpoint};
|
||||
use crate::chat::events::ToolDispatch;
|
||||
use crate::chat::tools::test_support::{serve, serve_raw};
|
||||
use std::sync::mpsc::channel;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const ROUND1: &str = include_str!("../../tests/fixtures/chat/openai_round1_tools.sse");
|
||||
const ROUND2: &str = include_str!("../../tests/fixtures/chat/openai_round2_end.sse");
|
||||
const TEST_KEY: &str = "sk-test-secret-456";
|
||||
|
||||
fn endpoint() -> Endpoint {
|
||||
Endpoint { id: "local".into(), name: "Local".into(), format: ApiFormat::OpenAi, base_url: "http://127.0.0.1:1/v1".into(), model: "llama-x".into() }
|
||||
}
|
||||
|
||||
fn cfg(name: &str, url: String, tools: Option<ToolDispatch>) -> TurnConfig {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-openai-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
TurnConfig {
|
||||
system: "sys".into(),
|
||||
api_history: Some(dir.join("chats").join("c.api.json")),
|
||||
api_base_url: Some(url),
|
||||
endpoint: endpoint(),
|
||||
tools,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cfg: &TurnConfig, mut on_event: impl FnMut(&ApiTurn, &Event)) -> Vec<Event> {
|
||||
let (tx, rx) = channel();
|
||||
let turn = start_with_key(cfg, "make a board", TEST_KEY.into(), tx).unwrap();
|
||||
let mut out = vec![];
|
||||
while let Ok(e) = rx.recv_timeout(Duration::from_secs(10)) {
|
||||
on_event(&turn, &e);
|
||||
let end = matches!(e, Event::TurnEnd { .. } | Event::Error(_));
|
||||
out.push(e);
|
||||
if end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn history(cfg: &TurnConfig) -> Vec<Value> {
|
||||
serde_json::from_slice(&std::fs::read(cfg.api_history.as_ref().unwrap()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn fake_tools(calls: Arc<Mutex<Vec<String>>>) -> ToolDispatch {
|
||||
ToolDispatch(Arc::new(move |c| {
|
||||
calls.lock().unwrap().push(c.name.clone());
|
||||
let out = match c.name.as_str() {
|
||||
"get_basic_info" => vec![Out::Text("info".into()), Out::Image { data: vec![1, 2, 3], mime: "image/png".into() }],
|
||||
_ => vec![Out::Text("created".into())],
|
||||
};
|
||||
let _ = c.reply.send(Ok(out));
|
||||
true
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_shape_has_only_portable_fields() {
|
||||
let tools = tools_json();
|
||||
let c = TurnConfig { system: "SYS".into(), model: Some("m".into()), effort: Some("high".into()), endpoint: endpoint(), ..Default::default() };
|
||||
let b = build_body(&c, &tools, &[json!({"role": "user", "content": "hi"})]);
|
||||
let mut keys: Vec<&str> = b.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["messages", "model", "stream", "stream_options", "tools"]);
|
||||
assert_eq!((b["model"].as_str(), b["stream"].as_bool()), (Some("m"), Some(true)));
|
||||
assert_eq!(b["stream_options"], json!({"include_usage": true}));
|
||||
assert_eq!(b["messages"], json!([{"role": "system", "content": "SYS"}, {"role": "user", "content": "hi"}]));
|
||||
let ts = b["tools"].as_array().unwrap();
|
||||
let names: Vec<String> = crate::mcp::server::tool_list().iter().map(|t| t.name.to_string()).collect();
|
||||
assert_eq!(ts.iter().map(|t| t["function"]["name"].as_str().unwrap().to_string()).collect::<Vec<_>>(), names);
|
||||
assert!(ts.iter().all(|t| t["type"] == "function" && t["function"]["parameters"]["type"] == "object" && t["function"]["description"].is_string()));
|
||||
assert_eq!(tools_json(), tools, "stable across calls");
|
||||
// The model falls back to the endpoint's.
|
||||
assert_eq!(build_body(&TurnConfig { endpoint: endpoint(), ..Default::default() }, &[], &[])["model"], "llama-x");
|
||||
// Even on the Claude host: nothing Claude-only in this format.
|
||||
let on_claude = TurnConfig { endpoint: Endpoint { format: ApiFormat::OpenAi, ..Default::default() }, ..Default::default() };
|
||||
let b = build_body(&on_claude, &tools, &[]);
|
||||
assert!(b.get("output_config").is_none() && b.get("thinking").is_none() && b.get("fallbacks").is_none());
|
||||
assert!(!b.to_string().contains("cache_control") && !b.to_string().contains("eager_input_streaming"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_bearer_only_with_a_key() {
|
||||
assert!(build_headers("K").contains(&("authorization", "Bearer K".into())));
|
||||
assert!(build_headers("").iter().all(|(k, _)| *k != "authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_become_image_url_data_uris_before_the_text() {
|
||||
assert_eq!(user_content("hi", &[]), json!("hi"));
|
||||
let img = crate::chat::events::ImageAttachment { mime: "image/png".into(), data: vec![1, 2, 3] };
|
||||
let v = user_content("hi", &[img]);
|
||||
assert_eq!(v[0], json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}}));
|
||||
assert_eq!(v[1], json!({"type": "text", "text": "hi"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_loop_two_rounds() {
|
||||
let (url, seen) = serve(vec![ROUND1, ROUND2]);
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let c = cfg("loop", url, Some(fake_tools(calls.clone())));
|
||||
let ev = run(&c, |_, _| {});
|
||||
let contexts: Vec<u64> = ev.iter().filter_map(|e| if let Event::Context(n) = e { Some(*n) } else { None }).collect();
|
||||
assert_eq!(contexts, [100, 160], "{ev:?}");
|
||||
let kinds: Vec<&str> = ev
|
||||
.iter()
|
||||
.filter(|e| !matches!(e, Event::Context(_)))
|
||||
.map(|e| match e {
|
||||
Event::Text(_) => "text",
|
||||
Event::ToolStart { .. } => "start",
|
||||
Event::ToolEnd { .. } => "end",
|
||||
Event::TurnEnd { .. } => "turn",
|
||||
_ => "other",
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["text", "start", "start", "end", "end", "text", "text", "turn"], "{ev:?}");
|
||||
assert_eq!(*calls.lock().unwrap(), ["create_artboard", "get_basic_info"]);
|
||||
let Event::TurnEnd { usage: Some(u), stop_reason, .. } = ev.last().unwrap() else { panic!() };
|
||||
assert_eq!(stop_reason.as_deref(), Some("stop"));
|
||||
// Round 1: 100 prompt (20 cached), 30 out. Round 2: 160 prompt, 12 out.
|
||||
assert_eq!((u.input, u.output, u.cache_read, u.cache_write), (80 + 160, 42, 20, 0));
|
||||
assert_eq!(u.cost_usd, None, "no price for a non-Claude model");
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
let (h1, b1) = &seen[0];
|
||||
assert!(h1.contains(&format!("authorization: bearer {}", TEST_KEY.to_lowercase())));
|
||||
assert!(!h1.iter().any(|h| h.starts_with("x-api-key") || h.starts_with("anthropic")));
|
||||
assert_eq!(b1["messages"].as_array().unwrap().len(), 2, "system + user");
|
||||
let m = seen[1].1["messages"].as_array().unwrap();
|
||||
assert_eq!(m.len(), 6, "system, user, assistant, tool, tool, image user message");
|
||||
assert_eq!(m[2]["role"], "assistant");
|
||||
assert_eq!(m[2]["content"], "Creating it.");
|
||||
assert_eq!(m[2]["tool_calls"][0]["id"], "call_A");
|
||||
assert_eq!(m[2]["tool_calls"][0]["function"]["name"], "create_artboard");
|
||||
let args: Value = serde_json::from_str(m[2]["tool_calls"][0]["function"]["arguments"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(args, json!({"name": "Home", "styles": {"width": "1440px", "height": "900px"}}));
|
||||
assert_eq!(m[3], json!({"role": "tool", "tool_call_id": "call_A", "content": "created"}));
|
||||
assert_eq!(m[4], json!({"role": "tool", "tool_call_id": "call_B", "content": "info\n[image omitted]"}));
|
||||
assert_eq!(m[5]["role"], "user");
|
||||
assert_eq!(m[5]["content"][1], json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}}));
|
||||
|
||||
let h = history(&c);
|
||||
assert_eq!(h.len(), 6, "user, assistant, tool, tool, image, final assistant");
|
||||
assert_eq!(h[..5], m[1..]);
|
||||
assert_eq!(h[5], json!({"role": "assistant", "content": "Done: board ready."}));
|
||||
assert!(!serde_json::to_string(&h).unwrap().contains(TEST_KEY), "key must never reach the history file");
|
||||
}
|
||||
|
||||
fn delete_round() -> &'static str {
|
||||
let call = r#"{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_D","type":"function","function":{"name":"delete_pages","arguments":"{\"pageIds\":[\"p1\"]}"}}]}}]}"#;
|
||||
let fin = r#"{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#;
|
||||
Box::leak(format!("data: {call}\n\ndata: {fin}\n\ndata: [DONE]\n\n").into_boxed_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_tool_waits_for_approval() {
|
||||
for (ok, name) in [(false, "deny"), (true, "allow")] {
|
||||
let (url, seen) = serve(vec![delete_round(), ROUND2]);
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let c = cfg(name, url, Some(fake_tools(calls.clone())));
|
||||
let ev = run(&c, |t, e| {
|
||||
if let Event::NeedsApproval { id, .. } = e {
|
||||
t.approve(id, ok);
|
||||
}
|
||||
});
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::NeedsApproval { name, .. } if name == "delete_pages")), "{ev:?}");
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })), "{ev:?}");
|
||||
assert_eq!(calls.lock().unwrap().len(), usize::from(ok));
|
||||
if !ok {
|
||||
let tool_msg = &seen.lock().unwrap()[1].1["messages"][3];
|
||||
assert!(tool_msg["content"].as_str().unwrap().contains("denied"), "{tool_msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_and_bad_json_go_back_as_errors() {
|
||||
let bad = r#"{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"create_artboard","arguments":"{\"name\": 5}"}},{"index":1,"id":"c2","function":{"name":"get_basic_info","arguments":"{oops"}}]}}]}"#;
|
||||
let fin = r#"{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#;
|
||||
let round: &'static str = Box::leak(format!("data: {bad}\n\ndata: {fin}\n\ndata: [DONE]\n\n").into_boxed_str());
|
||||
let (url, seen) = serve(vec![round, ROUND2]);
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let ev = run(&cfg("invalid", url, Some(fake_tools(calls.clone()))), |_, _| {});
|
||||
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })), "{ev:?}");
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
let m = seen.lock().unwrap()[1].1["messages"].clone();
|
||||
assert!(m[3]["content"].as_str().unwrap().starts_with("Error: invalid input for `create_artboard`"), "{}", m[3]);
|
||||
assert!(m[4]["content"].as_str().unwrap().contains("invalid arguments"), "{}", m[4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_shows_message_and_redacts_the_key() {
|
||||
let b = format!(r#"{{"error":{{"message":"bad key {TEST_KEY}"}}}}"#);
|
||||
let (url, _) = serve_raw(vec![format!("HTTP/1.1 401 Unauthorized\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{b}", b.len())]);
|
||||
let ev = run(&cfg("401", url, None), |_, _| {});
|
||||
let Some(Event::Error(m)) = ev.last() else { panic!("{ev:?}") };
|
||||
assert!(m.contains("401") && m.contains("bad key") && !m.contains(TEST_KEY), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_cap_is_forty() {
|
||||
let (url, seen) = serve(vec![delete_round_plain(); MAX_TOOL_ROUNDS + 1]);
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let ev = run(&cfg("cap", url, Some(fake_tools(calls))), |_, _| {});
|
||||
let Some(Event::Error(m)) = ev.last() else { panic!("{ev:?}") };
|
||||
assert!(m.contains("Stopped after"), "{m}");
|
||||
assert_eq!(seen.lock().unwrap().len(), MAX_TOOL_ROUNDS + 1);
|
||||
}
|
||||
|
||||
fn delete_round_plain() -> &'static str {
|
||||
let call = r#"{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"get_basic_info","arguments":"{}"}}]}}]}"#;
|
||||
let fin = r#"{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#;
|
||||
Box::leak(format!("data: {call}\n\ndata: {fin}\n\ndata: [DONE]\n\n").into_boxed_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_on_disk_before_the_end_event() {
|
||||
let (url, _) = serve(vec![ROUND2]);
|
||||
let c = cfg("persist_first", url, None);
|
||||
let path = c.api_history.clone().unwrap();
|
||||
let mut on_disk = None;
|
||||
run(&c, |_, e| {
|
||||
if matches!(e, Event::TurnEnd { .. }) {
|
||||
on_disk = Some(path.exists());
|
||||
}
|
||||
});
|
||||
assert_eq!(on_disk, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_ends_a_hanging_stream_fast() {
|
||||
use std::io::{Read, Write};
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", l.local_addr().unwrap());
|
||||
std::thread::spawn(move || {
|
||||
let (mut s, _) = l.accept().unwrap();
|
||||
let mut buf = [0; 8192];
|
||||
let _ = s.read(&mut buf);
|
||||
let _ = write!(s, "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n: hold\n\n");
|
||||
std::thread::sleep(Duration::from_secs(20));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
use crate::chat::events::Turn;
|
||||
let mut turn = start_with_key(&cfg("stop", url, None), "x", TEST_KEY.into(), tx).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
turn.stop();
|
||||
let e = rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert!(matches!(e, Event::TurnEnd { stop_reason: Some(ref r), .. } if r == "stopped"), "{e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// SYNTHETIC fixture, hand-written from the documented Chat Completions streaming shape.
|
||||
const TOOLS: &str = include_str!("../../tests/fixtures/chat/openai_tools.sse");
|
||||
|
||||
/// Feed in awkward 7-byte chunks (fixtures are ASCII) to prove line buffering.
|
||||
fn run(src: &str) -> (ChatParser, Vec<Event>) {
|
||||
let mut p = ChatParser::default();
|
||||
let ev = src.as_bytes().chunks(7).flat_map(|c| p.push(std::str::from_utf8(c).unwrap())).collect();
|
||||
(p, ev)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_parallel_tool_calls_split_across_chunks() {
|
||||
let (p, ev) = run(TOOLS);
|
||||
assert_eq!(
|
||||
ev,
|
||||
vec![
|
||||
Event::Thinking("Need an artboard.".into()),
|
||||
Event::Text("Creating ".into()),
|
||||
Event::Text("the home screen.".into()),
|
||||
Event::ToolStart { id: "call_A".into(), name: "create_artboard".into(), args: json!({"name": "Home", "width": 1440}) },
|
||||
Event::ToolStart { id: "call_B".into(), name: "get_basic_info".into(), args: json!({}) },
|
||||
]
|
||||
);
|
||||
assert_eq!(p.text(), "Creating the home screen.");
|
||||
assert_eq!(p.finish_reason(), Some("tool_calls"));
|
||||
assert_eq!(p.tool_uses().len(), 2);
|
||||
let u = p.usage().unwrap();
|
||||
assert_eq!((u.input, u.output, u.cache_read), (120, 87, 900));
|
||||
assert_eq!(p.prompt_tokens(), 1020);
|
||||
assert!(p.done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_usage_missing_ids_and_object_arguments_are_tolerated() {
|
||||
let mut p = ChatParser::default();
|
||||
let ev = p.push(
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"get_basic_info\",\"arguments\":{\"a\":1}}}]},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
);
|
||||
let Event::ToolStart { id, name, args } = &ev[0] else { panic!("{ev:?}") };
|
||||
assert!(id.starts_with("call_") && id.len() > 5);
|
||||
assert_eq!((name.as_str(), args), ("get_basic_info", &json!({"a": 1})));
|
||||
assert!(p.usage().is_none());
|
||||
assert!(!p.done(), "no [DONE] yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_json_is_kept_as_an_error_not_a_panic() {
|
||||
let mut p = ChatParser::default();
|
||||
p.push("data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"t\",\"function\":{\"name\":\"x\",\"arguments\":\"{\\\"a\\\"\"}}]},\"finish_reason\":\"tool_calls\"}]}\n");
|
||||
assert!(p.tool_uses()[0].input.as_ref().unwrap_err().contains("invalid tool arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_stream_error_object() {
|
||||
let mut p = ChatParser::default();
|
||||
let ev = p.push("data: {\"error\":{\"message\":\"rate limited\"}}\n");
|
||||
assert_eq!(ev, vec![Event::Error("rate limited".into())]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The PATH agents are found and run with. An app opened from the Dock gets the bare system PATH
|
||||
//! (`/usr/bin:/bin:/usr/sbin:/sbin`), so `~/.local/bin` and Homebrew are invisible. The login
|
||||
//! shell's PATH is read once, off the UI thread, and merged with the process PATH and the usual
|
||||
//! install folders.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SHELL_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const POLL: Duration = Duration::from_millis(20);
|
||||
/// The PATH is framed by markers: rc/exit-hook noise on stdout before or after cannot replace it.
|
||||
const SHELL_SCRIPT: &str = r#"printf '\n__PC_PATH_BEGIN__%s__PC_PATH_END__\n' "$PATH""#;
|
||||
const PATH_BEGIN: &str = "__PC_PATH_BEGIN__";
|
||||
const PATH_END: &str = "__PC_PATH_END__";
|
||||
/// Same keys the agent spawns drop: a login shell must not hand them to anything.
|
||||
pub(super) const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
/// Under `$HOME`.
|
||||
const HOME_DIRS: [&str; 6] = [".local/bin", ".cargo/bin", ".bun/bin", ".npm-global/bin", ".volta/bin", ".nvm/current/bin"];
|
||||
const SYSTEM_DIRS: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
static RESOLVED: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Starts resolving in the background; call once at app start.
|
||||
pub fn init() {
|
||||
std::thread::spawn(|| {
|
||||
resolved_path();
|
||||
});
|
||||
}
|
||||
|
||||
/// The merged PATH. Blocks only while the first resolution is still running.
|
||||
pub fn resolved_path() -> &'static str {
|
||||
RESOLVED.get_or_init(|| {
|
||||
let shell = std::env::var("SHELL").ok().filter(|_| cfg!(unix)).and_then(|s| shell_path(&s, SHELL_SCRIPT, SHELL_TIMEOUT));
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
let extras = HOME_DIRS.iter().filter_map(|d| home.as_ref().map(|h| h.join(d))).chain(SYSTEM_DIRS.iter().map(PathBuf::from)).filter(|d| d.is_dir()).collect();
|
||||
merge(shell.as_deref().unwrap_or_default(), &std::env::var_os("PATH").unwrap_or_default(), extras)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shell PATH first (the user's own order), then the process PATH, then the extra folders; each
|
||||
/// folder once.
|
||||
fn merge(shell: &str, process: &OsStr, extras: Vec<PathBuf>) -> String {
|
||||
let mut out: Vec<PathBuf> = vec![];
|
||||
for d in std::env::split_paths(shell).chain(std::env::split_paths(process)).chain(extras) {
|
||||
if !d.as_os_str().is_empty() && !out.contains(&d) {
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
std::env::join_paths(out).map(|p| p.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Runs `shell -l -c script` and returns the text between the PATH markers; None on failure or timeout.
|
||||
fn shell_path(shell: &str, script: &str, timeout: Duration) -> Option<String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(["-l", "-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
for k in CREDENTIAL_ENV {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
let mut stdout = child.stdout.take()?;
|
||||
// Read on a thread: a shell stuck in an rc file never closes the pipe.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = stdout.read_to_string(&mut s);
|
||||
let _ = tx.send(s);
|
||||
});
|
||||
let start = Instant::now();
|
||||
let out = loop {
|
||||
match rx.recv_timeout(POLL) {
|
||||
Ok(s) => break Some(s),
|
||||
Err(_) if start.elapsed() >= timeout => break None,
|
||||
Err(_) => {}
|
||||
}
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let out = out?;
|
||||
let from = out.rfind(PATH_BEGIN)? + PATH_BEGIN.len();
|
||||
let len = out[from..].find(PATH_END)?;
|
||||
Some(out[from..from + len].trim().to_string()).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// First executable named `name` on the resolved PATH.
|
||||
pub fn which(name: &str) -> Option<PathBuf> {
|
||||
which_in(name, OsStr::new(resolved_path()))
|
||||
}
|
||||
|
||||
pub(super) fn which_in(name: &str, path: &OsStr) -> Option<PathBuf> {
|
||||
let names: Vec<String> = if cfg!(windows) { ["exe", "cmd", "bat"].iter().map(|e| format!("{name}.{e}")).collect() } else { vec![name.into()] };
|
||||
std::env::split_paths(path).flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| is_executable(p))
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
p.is_file()
|
||||
}
|
||||
|
||||
/// Sets the merged PATH on a child, so node-based CLIs find `node`.
|
||||
pub fn apply(cmd: &mut Command) -> &mut Command {
|
||||
cmd.env("PATH", OsString::from(resolved_path()))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_dedupes_and_keeps_order() {
|
||||
let m = merge("/a:/b:/a", OsStr::new("/b:/c::/d"), vec!["/d".into(), "/e".into()]);
|
||||
assert_eq!(m, "/a:/b:/c:/d:/e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_finds_only_executables_on_the_given_path() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("pc-path-{}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (bin, plain) = (dir.join("fake-agent"), dir.join("not-exec"));
|
||||
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&plain, "x").unwrap();
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = std::env::join_paths(["/nonexistent", dir.to_str().unwrap()]).unwrap();
|
||||
assert_eq!(which_in("fake-agent", &path), Some(bin));
|
||||
assert_eq!(which_in("not-exec", &path), None);
|
||||
assert_eq!(which_in("missing", &path), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_path_reads_between_markers_and_times_out() {
|
||||
let script = r#"echo noise; printf '\n__PC_PATH_BEGIN__%s__PC_PATH_END__\n' /x:/y; echo exit-hook-noise"#;
|
||||
assert_eq!(shell_path("/bin/sh", script, Duration::from_secs(3)).as_deref(), Some("/x:/y"));
|
||||
assert_eq!(shell_path("/bin/sh", SHELL_SCRIPT, Duration::from_secs(3)).is_some(), true);
|
||||
assert_eq!(shell_path("/bin/sh", "echo /only/noise", Duration::from_secs(3)), None);
|
||||
let t = Instant::now();
|
||||
assert_eq!(shell_path("/bin/sh", "sleep 10", Duration::from_millis(200)), None);
|
||||
assert!(t.elapsed() < Duration::from_secs(3));
|
||||
assert_eq!(shell_path("/nonexistent-shell", SHELL_SCRIPT, Duration::from_secs(1)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Chat files in the project folder: `chats/<id>.json`, plus `chats/<id>.api.json` for the raw
|
||||
//! Anthropic API messages (exact replay, thinking blocks included).
|
||||
|
||||
use super::{valid_id, Chat, TMP_DIR};
|
||||
use crate::vfs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DIR: &str = "chats";
|
||||
const API_SUFFIX: &str = ".api.json";
|
||||
|
||||
pub use crate::project::format::new_id;
|
||||
|
||||
fn dir(project: &Path) -> PathBuf {
|
||||
project.join(DIR)
|
||||
}
|
||||
|
||||
fn file_of(project: &Path, id: &str) -> PathBuf {
|
||||
dir(project).join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
pub fn save(project: &Path, chat: &Chat) -> std::io::Result<()> {
|
||||
if !valid_id(&chat.id) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid chat id"));
|
||||
}
|
||||
vfs::create_dir_all(dir(project))?;
|
||||
let bytes = serde_json::to_vec(chat).map_err(std::io::Error::other)?;
|
||||
vfs::write(file_of(project, &chat.id), bytes)
|
||||
}
|
||||
|
||||
pub fn load(project: &Path, id: &str) -> Option<Chat> {
|
||||
if !valid_id(id) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice(&vfs::read(file_of(project, id)).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Every readable chat, newest updated first. Unreadable files are skipped.
|
||||
pub fn list(project: &Path) -> Vec<Chat> {
|
||||
let Ok(entries) = vfs::read_dir(dir(project)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<Chat> = entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter(|e| !e.path().to_string_lossy().ends_with(API_SUFFIX))
|
||||
.filter_map(|e| serde_json::from_slice::<Chat>(&vfs::read(e.path()).ok()?).ok())
|
||||
.filter(|c| valid_id(&c.id))
|
||||
.collect();
|
||||
out.sort_by_key(|c| std::cmp::Reverse(c.updated));
|
||||
out
|
||||
}
|
||||
|
||||
/// Removes the chat and its API replay file, if there is one.
|
||||
pub fn delete(project: &Path, id: &str) {
|
||||
if !valid_id(id) {
|
||||
return;
|
||||
}
|
||||
let _ = vfs::remove_file(file_of(project, id));
|
||||
let _ = vfs::remove_file(dir(project).join(format!("{id}{API_SUFFIX}")));
|
||||
}
|
||||
|
||||
/// Folder of the images saved for chat `id`; None when `id` is not a plain name.
|
||||
pub fn tmp_of(project: &Path, id: &str) -> Option<PathBuf> {
|
||||
valid_id(id).then(|| project.join(TMP_DIR).join(id))
|
||||
}
|
||||
|
||||
/// Removes the chat's saved images, only when the folder really lies inside
|
||||
/// `<project>/.papercraft-chat-tmp` (a symlink pointing elsewhere is left alone).
|
||||
pub fn remove_tmp(project: &Path, id: &str) {
|
||||
let Some(folder) = tmp_of(project, id) else { return };
|
||||
let (Ok(root), Ok(real)) = (std::fs::canonicalize(project.join(TMP_DIR)), std::fs::canonicalize(&folder)) else { return };
|
||||
if real != root && real.starts_with(&root) {
|
||||
let _ = vfs::remove_dir_all(real);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::{Backend, BackendKind, Message, Role, ToolCall, Usage};
|
||||
|
||||
fn temp_project() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("papercraft-chat-{:016x}", fastrand::u64(..)))
|
||||
}
|
||||
|
||||
fn chat(id: &str, updated: u64) -> Chat {
|
||||
Chat {
|
||||
id: id.into(),
|
||||
title: format!("Chat {id}"),
|
||||
created: 1,
|
||||
updated,
|
||||
backend: Backend {
|
||||
kind: BackendKind::Codex,
|
||||
model: Some("gpt-6.1-sol".into()),
|
||||
effort: None,
|
||||
endpoint: None,
|
||||
cli_session: Some("s1".into()),
|
||||
agent: None,
|
||||
},
|
||||
messages: vec![Message {
|
||||
role: Role::Assistant,
|
||||
text: "Done".into(),
|
||||
tools: vec![ToolCall {
|
||||
id: "t1".into(),
|
||||
name: "create_artboard".into(),
|
||||
args: serde_json::json!({ "w": 390 }),
|
||||
result: Some(serde_json::json!({ "ok": true })),
|
||||
error: None,
|
||||
ms: Some(12),
|
||||
}],
|
||||
run_id: Some("r1".into()),
|
||||
context: Some(7),
|
||||
usage: Some(Usage {
|
||||
input: 10,
|
||||
output: 4,
|
||||
cache_read: 2,
|
||||
cache_write: 0,
|
||||
cost_usd: Some(0.5),
|
||||
}),
|
||||
at: 5,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_round_trips() {
|
||||
let dir = temp_project();
|
||||
let c = chat("aaaa", 3);
|
||||
save(&dir, &c).unwrap();
|
||||
assert_eq!(load(&dir, "aaaa"), Some(c));
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_orders_newest_first_and_skips_unreadable() {
|
||||
let dir = temp_project();
|
||||
save(&dir, &chat("old", 1)).unwrap();
|
||||
save(&dir, &chat("new", 9)).unwrap();
|
||||
vfs::write(dir.join(DIR).join("broken.json"), b"{not json").unwrap();
|
||||
let ids: Vec<String> = list(&dir).into_iter().map(|c| c.id).collect();
|
||||
assert_eq!(ids, vec!["new", "old"]);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_chat_and_api_file() {
|
||||
let dir = temp_project();
|
||||
save(&dir, &chat("a", 1)).unwrap();
|
||||
vfs::write(dir.join(DIR).join("a.api.json"), b"[]").unwrap();
|
||||
delete(&dir, "a");
|
||||
assert!(!dir.join(DIR).join("a.json").exists());
|
||||
assert!(!dir.join(DIR).join("a.api.json").exists());
|
||||
assert!(list(&dir).is_empty());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ids_never_reach_the_file_system() {
|
||||
let dir = temp_project();
|
||||
let outside = dir.join("victim");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::create_dir_all(dir.join(TMP_DIR)).unwrap();
|
||||
for id in ["..", "../victim", "a/b", ".", "", "a.b", "..\\victim"] {
|
||||
assert!(!valid_id(id), "{id}");
|
||||
assert!(tmp_of(&dir, id).is_none());
|
||||
remove_tmp(&dir, id);
|
||||
delete(&dir, id);
|
||||
assert!(load(&dir, id).is_none());
|
||||
assert!(save(&dir, &chat(id, 1)).is_err());
|
||||
}
|
||||
assert!(outside.exists());
|
||||
assert!(dir.join(TMP_DIR).exists(), "the tmp root itself survives");
|
||||
// A file on disk whose content claims a hostile id is not listed.
|
||||
std::fs::create_dir_all(dir.join(DIR)).unwrap();
|
||||
vfs::write(dir.join(DIR).join("x.json"), br#"{"id":"../victim"}"#).unwrap();
|
||||
assert!(list(&dir).is_empty());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remove_tmp_ignores_a_symlink_out_of_the_tmp_dir() {
|
||||
let dir = temp_project();
|
||||
let outside = dir.join("victim");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::write(outside.join("keep"), b"x").unwrap();
|
||||
std::fs::create_dir_all(dir.join(TMP_DIR)).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join(TMP_DIR).join("abc")).unwrap();
|
||||
remove_tmp(&dir, "abc");
|
||||
assert!(outside.join("keep").exists());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_without_chats_dir_lists_empty() {
|
||||
let dir = temp_project();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(list(&dir).is_empty());
|
||||
assert!(load(&dir, "x").is_none());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Pieces shared by the two API loops (`anthropic`, `openai`): the tool list, the input validator,
|
||||
//! approvals, running a tool on the UI thread, the turn handle, the event sink and the HTTP
|
||||
//! request with its stop polling.
|
||||
|
||||
use super::events::{Event, ToolDispatch, Turn};
|
||||
use crate::mcp::Out;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const MAX_TOOL_ROUNDS: usize = 40;
|
||||
/// How often blocking waits look at the stop flag (stop must end the turn within 3 s).
|
||||
pub const POLL: Duration = Duration::from_millis(100);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// An HTTP error body is a short JSON message; never buffer more than this.
|
||||
const ERROR_BODY_MAX: usize = 64 * 1024;
|
||||
pub const STOPPED_BY_USER: &str = "stopped by user; this call was not completed or its outcome is unknown";
|
||||
pub const DENIED: &str = "The user denied this action.";
|
||||
/// Tool results above this many layers in one `delete_nodes` need the user's approval.
|
||||
const DELETE_NODES_CONFIRM_ABOVE: usize = 20;
|
||||
pub const CONTINUE_NOTE: &str = "Your previous reply was cut off by the output limit. Continue where you stopped.";
|
||||
pub const CUT_OFF: &str = "output limit reached before the call finished; not run";
|
||||
|
||||
/// The app's tools as `{name, description, inputSchema}`, in `tool_list()` order.
|
||||
pub fn tool_specs() -> Vec<Value> {
|
||||
crate::mcp::server::tool_list()
|
||||
.iter()
|
||||
.filter_map(|t| serde_json::to_value(t).ok())
|
||||
.map(|t| json!({"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- validation
|
||||
|
||||
/// Minimal JSON-schema check: object shape, required fields, and the JSON type of each
|
||||
/// declared property (recursing into properties and array items).
|
||||
pub fn validate_input(schema: &Value, input: &Value) -> Result<(), String> {
|
||||
check(schema, input, "input")
|
||||
}
|
||||
|
||||
fn type_ok(t: &str, v: &Value) -> bool {
|
||||
match t {
|
||||
"string" => v.is_string(),
|
||||
"boolean" => v.is_boolean(),
|
||||
"number" => v.is_number(),
|
||||
"integer" => v.is_i64() || v.is_u64() || v.as_f64().is_some_and(|f| f.fract() == 0.0),
|
||||
"array" => v.is_array(),
|
||||
"object" => v.is_object(),
|
||||
"null" => v.is_null(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn check(schema: &Value, v: &Value, path: &str) -> Result<(), String> {
|
||||
let ok = match schema.get("type") {
|
||||
Some(Value::String(t)) => type_ok(t, v),
|
||||
Some(Value::Array(ts)) => ts.iter().filter_map(Value::as_str).any(|t| type_ok(t, v)),
|
||||
_ => true,
|
||||
};
|
||||
if !ok {
|
||||
return Err(format!("{path}: expected {}, got {v}", schema["type"]));
|
||||
}
|
||||
if let Some(e) = schema["enum"].as_array() {
|
||||
if !e.contains(v) {
|
||||
return Err(format!("{path}: {v} is not one of {}", schema["enum"]));
|
||||
}
|
||||
}
|
||||
if let Some(n) = v.as_f64() {
|
||||
if schema["minimum"].as_f64().is_some_and(|m| n < m) {
|
||||
return Err(format!("{path}: {n} is below the minimum {}", schema["minimum"]));
|
||||
}
|
||||
if schema["maximum"].as_f64().is_some_and(|m| n > m) {
|
||||
return Err(format!("{path}: {n} is above the maximum {}", schema["maximum"]));
|
||||
}
|
||||
}
|
||||
if let Some(s) = v.as_str() {
|
||||
if schema["minLength"].as_u64().is_some_and(|m| (s.chars().count() as u64) < m) {
|
||||
return Err(format!("{path}: shorter than {} characters", schema["minLength"]));
|
||||
}
|
||||
}
|
||||
// anyOf / oneOf: at least one branch must accept (oneOf's "exactly one" is not enforced).
|
||||
for key in ["anyOf", "oneOf"] {
|
||||
if let Some(branches) = schema[key].as_array() {
|
||||
if !branches.iter().any(|b| check(b, v, path).is_ok()) {
|
||||
return Err(format!("{path}: {v} matches none of the allowed {key} shapes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(o) = v.as_object() {
|
||||
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if !o.contains_key(r) {
|
||||
return Err(format!("{path}: missing required field `{r}`"));
|
||||
}
|
||||
}
|
||||
let props = schema["properties"].as_object();
|
||||
for (k, val) in o {
|
||||
let child = format!("{path}.{k}");
|
||||
match (props.and_then(|p| p.get(k)), &schema["additionalProperties"]) {
|
||||
(Some(s), _) => check(s, val, &child)?,
|
||||
(None, Value::Bool(false)) => return Err(format!("{path}: unexpected field `{k}`")),
|
||||
(None, extra @ Value::Object(_)) => check(extra, val, &child)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = v.as_array() {
|
||||
if schema["minItems"].as_u64().is_some_and(|m| (a.len() as u64) < m) {
|
||||
return Err(format!("{path}: needs at least {} items", schema["minItems"]));
|
||||
}
|
||||
if let Some(items) = schema.get("items") {
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
check(items, x, &format!("{path}[{i}]"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destructive tools wait for the user (spec, Confirmation).
|
||||
pub fn needs_approval(name: &str, args: &Value) -> bool {
|
||||
match name {
|
||||
"delete_pages" | "disable_library" | "import_design_system" => true,
|
||||
"delete_nodes" => args["nodeIds"].as_array().is_some_and(|a| a.len() > DELETE_NODES_CONFIRM_ABOVE),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- running a tool
|
||||
|
||||
/// Tool output as content blocks (text and base64 images) for the `ToolEnd` event and the
|
||||
/// Messages API; never empty.
|
||||
pub fn out_blocks(outs: &[Out]) -> Vec<Value> {
|
||||
let mut v: Vec<Value> = outs
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Out::Text(t) => json!({"type": "text", "text": t}),
|
||||
Out::Image { data, mime } => json!({"type": "image", "source": {"type": "base64", "media_type": mime, "data": STANDARD.encode(data)}}),
|
||||
})
|
||||
.collect();
|
||||
if v.is_empty() {
|
||||
v.push(json!({"type": "text", "text": "OK"}));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// What a tool run needs from its worker.
|
||||
pub struct Ctl<'a> {
|
||||
pub stop: &'a AtomicBool,
|
||||
pub approvals: &'a Receiver<(String, bool)>,
|
||||
pub dispatch: Option<&'a ToolDispatch>,
|
||||
pub emit: &'a dyn Fn(Event),
|
||||
}
|
||||
|
||||
impl Ctl<'_> {
|
||||
fn stopped(&self) -> bool {
|
||||
self.stop.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Validates, waits for approval when needed, runs on the UI thread. `None` = stopped.
|
||||
pub async fn run_one(&self, specs: &[Value], id: &str, name: &str, args: &Value) -> Option<Result<Vec<Out>, String>> {
|
||||
let Some(tool) = specs.iter().find(|t| t["name"] == name) else {
|
||||
return Some(Err(format!("unknown tool `{name}`")));
|
||||
};
|
||||
if let Err(e) = validate_input(&tool["inputSchema"], args) {
|
||||
return Some(Err(format!("invalid input for `{name}`: {e}")));
|
||||
}
|
||||
if needs_approval(name, args) {
|
||||
(self.emit)(Event::NeedsApproval { id: id.into(), name: name.into(), args: args.clone() });
|
||||
loop {
|
||||
if self.stopped() {
|
||||
return None;
|
||||
}
|
||||
match self.approvals.recv_timeout(POLL) {
|
||||
Ok((i, ok)) if i == id => {
|
||||
if ok {
|
||||
break;
|
||||
}
|
||||
return Some(Err(DENIED.into()));
|
||||
}
|
||||
Ok(_) | Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => return Some(Err(DENIED.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(d) = self.dispatch else { return Some(Err("tools are not available".into())) };
|
||||
let (reply, mut rx) = tokio::sync::oneshot::channel();
|
||||
if !(d.0)(crate::mcp::Call { name: name.into(), args: args.clone(), reply }) {
|
||||
return Some(Err("the app is not accepting tool calls".into()));
|
||||
}
|
||||
loop {
|
||||
match tokio::time::timeout(POLL, &mut rx).await {
|
||||
Ok(Ok(r)) => return Some(r),
|
||||
Ok(Err(_)) => return Some(Err("the app dropped the tool call".into())),
|
||||
Err(_) if self.stopped() => return None,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ToolEnd` event for a finished call.
|
||||
pub fn end_event(id: &str, r: &Result<Vec<Out>, String>) -> Event {
|
||||
match r {
|
||||
Ok(outs) => Event::ToolEnd { id: id.into(), result: Some(Value::Array(out_blocks(outs))), error: None },
|
||||
Err(e) => Event::ToolEnd { id: id.into(), result: None, error: Some(e.clone()) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- turn handle, sink
|
||||
|
||||
/// A running API turn. `approve` answers a `NeedsApproval` event.
|
||||
pub struct ApiTurn {
|
||||
pub(crate) stop: Arc<AtomicBool>,
|
||||
running: Arc<AtomicBool>,
|
||||
approvals: Sender<(String, bool)>,
|
||||
}
|
||||
|
||||
impl ApiTurn {
|
||||
pub fn approve(&self, id: &str, ok: bool) {
|
||||
let _ = self.approvals.send((id.to_string(), ok));
|
||||
}
|
||||
|
||||
/// Runs `work(stop, approvals)` on a named thread; the turn counts as running until it returns.
|
||||
pub fn spawn(name: &str, work: impl FnOnce(Arc<AtomicBool>, Receiver<(String, bool)>) + Send + 'static) -> Result<ApiTurn, String> {
|
||||
let (approvals, approvals_rx) = channel();
|
||||
let turn = ApiTurn { stop: Arc::new(AtomicBool::new(false)), running: Arc::new(AtomicBool::new(true)), approvals };
|
||||
let (stop, running) = (turn.stop.clone(), turn.running.clone());
|
||||
std::thread::Builder::new()
|
||||
.name(name.into())
|
||||
.spawn(move || {
|
||||
work(stop, approvals_rx);
|
||||
running.store(false, Ordering::SeqCst);
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(turn)
|
||||
}
|
||||
}
|
||||
|
||||
impl Turn for ApiTurn {
|
||||
/// The worker polls the flag every 100 ms and drops the connection.
|
||||
fn stop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends events; the turn's `TurnEnd` or `Error` is held back until `flush`, so the history is on
|
||||
/// disk before the app sees the end (it deletes or reopens the chat right then).
|
||||
pub struct Sink {
|
||||
tx: Sender<Event>,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
terminal: std::cell::RefCell<Option<Event>>,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(tx: Sender<Event>, stop: Arc<AtomicBool>) -> Self {
|
||||
Self { tx, stop, terminal: Default::default() }
|
||||
}
|
||||
|
||||
pub fn stopped(&self) -> bool {
|
||||
self.stop.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn emit(&self, e: Event) {
|
||||
if matches!(e, Event::TurnEnd { .. } | Event::Error(_)) {
|
||||
self.terminal.borrow_mut().get_or_insert(e);
|
||||
return;
|
||||
}
|
||||
if self.tx.send(e).is_err() {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flush(&self) {
|
||||
if let Some(e) = self.terminal.take() {
|
||||
let _ = self.tx.send(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages only change in consistent steps, so this file is always replayable.
|
||||
pub fn persist(path: Option<&std::path::PathBuf>, messages: &[Value]) {
|
||||
let Some(p) = path else { return };
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let r = serde_json::to_vec(messages).map_err(|e| e.to_string()).and_then(|b| std::fs::write(p, b).map_err(|e| e.to_string()));
|
||||
if let Err(e) = r {
|
||||
log::warn!("chat history not saved: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `chats/<id>.api.json`; a missing file is an empty history.
|
||||
pub fn load_history(path: Option<&std::path::PathBuf>) -> Result<Vec<Value>, String> {
|
||||
match path.map(std::fs::read) {
|
||||
Some(Ok(b)) => serde_json::from_slice(&b).map_err(|e| format!("chat history is unreadable: {e}")),
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- HTTP
|
||||
|
||||
pub enum Halt {
|
||||
Stopped,
|
||||
/// An error event was already sent.
|
||||
Reported,
|
||||
Fail(String),
|
||||
}
|
||||
|
||||
pub fn client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder().connect_timeout(CONNECT_TIMEOUT).read_timeout(READ_TIMEOUT).build().map_err(|e| format!("http client: {e}"))
|
||||
}
|
||||
|
||||
/// Header names that carry the key; marked sensitive so they never show up in debug output.
|
||||
const SENSITIVE: [&str; 2] = ["x-api-key", "authorization"];
|
||||
|
||||
/// POSTs `body`; on a non-2xx answer returns `Fail("<label> error <status>: <message>")`.
|
||||
/// Stop is polled every 100 ms, also while the error body is read.
|
||||
pub async fn post(stop: &AtomicBool, client: &reqwest::Client, url: &str, headers: Vec<(&'static str, String)>, body: &Value, label: &str) -> Result<reqwest::Response, Halt> {
|
||||
let mut req = client.post(url).body(body.to_string());
|
||||
for (k, v) in headers {
|
||||
let mut hv = reqwest::header::HeaderValue::from_str(&v).map_err(|_| Halt::Fail(format!("invalid {k} header value")))?;
|
||||
hv.set_sensitive(SENSITIVE.contains(&k));
|
||||
req = req.header(k, hv);
|
||||
}
|
||||
let send = req.send();
|
||||
tokio::pin!(send);
|
||||
let mut resp = loop {
|
||||
match tokio::time::timeout(POLL, &mut send).await {
|
||||
Ok(r) => break r.map_err(|e| Halt::Fail(format!("request failed: {}", e.without_url())))?,
|
||||
Err(_) if stop.load(Ordering::SeqCst) => return Err(Halt::Stopped),
|
||||
Err(_) => {}
|
||||
}
|
||||
};
|
||||
if resp.status().is_success() {
|
||||
return Ok(resp);
|
||||
}
|
||||
let status = resp.status();
|
||||
let mut bytes: Vec<u8> = vec![];
|
||||
while bytes.len() < ERROR_BODY_MAX {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Err(Halt::Stopped);
|
||||
}
|
||||
match tokio::time::timeout(POLL, resp.chunk()).await {
|
||||
Err(_) => {}
|
||||
Ok(Ok(Some(c))) => bytes.extend_from_slice(&c),
|
||||
Ok(_) => break,
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let v = serde_json::from_str::<Value>(&text).ok();
|
||||
// `{"error":{"message":..}}` (both formats), `{"error":"..."}` or `{"message":..}`.
|
||||
let msg = v.as_ref().and_then(|v| v["error"]["message"].as_str().or_else(|| v["error"].as_str()).or_else(|| v["message"].as_str()).map(str::to_string));
|
||||
Err(Halt::Fail(format!("{label} error {status}: {}", msg.unwrap_or(text))))
|
||||
}
|
||||
|
||||
/// Feeds the response body to `feed` as UTF-8 text until it returns true (the reply is complete) or
|
||||
/// the body ends. Stop drops the connection.
|
||||
pub async fn read_stream(stop: &AtomicBool, mut resp: reqwest::Response, mut feed: impl FnMut(&str) -> bool) -> Result<(), Halt> {
|
||||
let mut pending: Vec<u8> = vec![];
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Err(Halt::Stopped);
|
||||
}
|
||||
let chunk = match tokio::time::timeout(POLL, resp.chunk()).await {
|
||||
Err(_) => continue,
|
||||
Ok(r) => r.map_err(|e| Halt::Fail(format!("stream error: {}", e.without_url())))?,
|
||||
};
|
||||
let Some(chunk) = chunk else { return Ok(()) };
|
||||
pending.extend_from_slice(&chunk);
|
||||
let valid = std::str::from_utf8(&pending).map_or_else(|e| e.valid_up_to(), str::len);
|
||||
let text = String::from_utf8_lossy(&pending[..valid]).into_owned();
|
||||
pending.drain(..valid);
|
||||
if feed(&text) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `fut` on a fresh current-thread runtime, as the workers do.
|
||||
pub fn block_on<F: std::future::Future<Output = ()>>(fut: F) -> Result<(), String> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| format!("could not start the network runtime: {e}"))?;
|
||||
rt.block_on(fut);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces anything that looks like the key in an error message (servers sometimes echo it).
|
||||
pub fn redact(msg: &str, key: &str) -> String {
|
||||
if key.len() < 8 { msg.to_string() } else { msg.replace(key, "[redacted]") }
|
||||
}
|
||||
|
||||
/// A fake HTTP server for the loop tests.
|
||||
#[cfg(test)]
|
||||
pub mod test_support {
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// `(lowercased request headers, JSON body)` per request.
|
||||
pub type Seen = Arc<Mutex<Vec<(Vec<String>, Value)>>>;
|
||||
|
||||
/// Serves one canned SSE response per connection, in order; records headers and body.
|
||||
pub fn serve(responses: Vec<&'static str>) -> (String, Seen) {
|
||||
let ok = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n";
|
||||
serve_raw(responses.into_iter().map(|r| format!("{ok}{r}")).collect())
|
||||
}
|
||||
|
||||
pub fn serve_raw(responses: Vec<String>) -> (String, Seen) {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", l.local_addr().unwrap());
|
||||
let seen: Seen = Arc::default();
|
||||
let s = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for resp in responses {
|
||||
let (mut sock, _) = l.accept().unwrap();
|
||||
let mut rd = BufReader::new(sock.try_clone().unwrap());
|
||||
let (mut headers, mut len) = (vec![], 0usize);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
rd.read_line(&mut line).unwrap();
|
||||
let line = line.trim_end().to_lowercase();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(n) = line.strip_prefix("content-length:") {
|
||||
len = n.trim().parse().unwrap();
|
||||
}
|
||||
headers.push(line);
|
||||
}
|
||||
let mut body = vec![0; len];
|
||||
rd.read_exact(&mut body).unwrap();
|
||||
s.lock().unwrap().push((headers, serde_json::from_slice(&body).unwrap()));
|
||||
let _ = sock.write_all(resp.as_bytes());
|
||||
}
|
||||
});
|
||||
(url, seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! The logic behind `papercraft-cli` that doesn't need a network: running tools in-process
|
||||
//! when the app is closed (`--offline`), the safety check against a second writer, and parsing
|
||||
//! shell scripts. The binary (`src/bin/papercraft-cli.rs`) does the I/O.
|
||||
|
||||
use crate::mcp::exec::{self, Host, SessionHost};
|
||||
use crate::mcp::{Out, ToolResult, tools};
|
||||
use crate::project::Store;
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// Tools that work with no project open, so they never trigger the default-project open.
|
||||
const NO_PROJECT_TOOLS: [&str; 6] = [
|
||||
"create_file",
|
||||
"open_file",
|
||||
"list_resources",
|
||||
"get_guide",
|
||||
"get_font_family_info",
|
||||
"search_icons",
|
||||
];
|
||||
|
||||
/// An in-process host on a project store: the same tools as the app, saved to disk after
|
||||
/// every editing call.
|
||||
pub struct Offline {
|
||||
pub host: SessionHost,
|
||||
/// The top-level node (artboard) the last editing call touched, for `:watch`.
|
||||
pub last_touched: Option<String>,
|
||||
/// Checked with `open_file`'s `fileId` before it runs (the binary refuses projects the app has open).
|
||||
pub open_guard: Option<Box<dyn Fn(&str) -> Result<(), String>>>,
|
||||
default_tried: bool,
|
||||
}
|
||||
|
||||
impl Offline {
|
||||
/// `project` (name, id or path) is opened right away; without it the most recently modified
|
||||
/// project opens on the first call that needs one.
|
||||
pub fn new(store: Store, project: Option<&str>) -> Result<Offline, String> {
|
||||
let mut o = Offline {
|
||||
host: SessionHost::new(store),
|
||||
last_touched: None,
|
||||
open_guard: None,
|
||||
default_tried: project.is_some(),
|
||||
};
|
||||
if let Some(p) = project {
|
||||
exec::call(&mut o.host, "open_file", &json!({"fileId": p}))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
pub fn call(&mut self, name: &str, args: &Value) -> ToolResult {
|
||||
if let ("open_file", Some(g), Some(id)) = (name, &self.open_guard, args["fileId"].as_str()) {
|
||||
g(id)?;
|
||||
}
|
||||
if !self.default_tried && !NO_PROJECT_TOOLS.contains(&name) {
|
||||
self.default_tried = true;
|
||||
self.open_default();
|
||||
}
|
||||
let edits = tools::edits_doc(name, args);
|
||||
if edits && self.host.project().is_none() {
|
||||
return Err("no project is open; run create_file, then open_file".into());
|
||||
}
|
||||
let r = exec::call(&mut self.host, name, args)?;
|
||||
if edits {
|
||||
self.host.save()?;
|
||||
self.last_touched = touched_key(args, &r).and_then(|k| self.artboard_of(&k));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn open_default(&mut self) {
|
||||
if self.host.project().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(e) = self.host.store().list().first() {
|
||||
let id = e.manifest.id.clone();
|
||||
let _ = exec::call(&mut self.host, "open_file", &json!({"fileId": id}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a page (id, id prefix or name) the active one.
|
||||
pub fn switch_page(&mut self, key: &str) -> Result<String, String> {
|
||||
let i = exec::find_page(&self.host.ed, key)?;
|
||||
self.host.ed.switch_page(i);
|
||||
Ok(self.host.ed.pages[i].name.clone())
|
||||
}
|
||||
|
||||
/// The top-level ancestor of a `"<page>:<n>"` node key.
|
||||
fn artboard_of(&mut self, key: &str) -> Option<String> {
|
||||
let ed = &self.host.ed;
|
||||
let (pfx, n) = key.split_once(':')?;
|
||||
let page = exec::find_page(ed, pfx).ok()?;
|
||||
let doc = ed.page_doc(page);
|
||||
let mut id = crate::model::NodeId(n.parse().ok()?);
|
||||
doc.get(id)?;
|
||||
while let Some(p) = doc.get(id)?.parent {
|
||||
id = p;
|
||||
}
|
||||
Some(exec::node_key(ed, page, id))
|
||||
}
|
||||
}
|
||||
|
||||
/// The node an editing call was about: from its arguments, else from its result.
|
||||
pub fn touched_key(args: &Value, result: &[Out]) -> Option<String> {
|
||||
let from_args = ["nodeId", "targetNodeId"]
|
||||
.iter()
|
||||
.find_map(|k| args[k].as_str())
|
||||
.or_else(|| args["nodeIds"][0].as_str());
|
||||
if let Some(k) = from_args {
|
||||
return Some(k.to_string());
|
||||
}
|
||||
let Some(Out::Text(t)) = result.first() else {
|
||||
return None;
|
||||
};
|
||||
let v: Value = serde_json::from_str(t).ok()?;
|
||||
["id", "nodeId"]
|
||||
.iter()
|
||||
.find_map(|k| v[k].as_str())
|
||||
.or_else(|| v["ids"][0].as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// `--offline` with the app running: refuse when the app has the target project open.
|
||||
/// `items` is the app's `list_resources` result; `target` is `--project`, or None for the
|
||||
/// default (most recently modified, the first item).
|
||||
pub fn refuse_if_open(items: &Value, target: Option<&str>) -> Result<(), String> {
|
||||
let list = items["items"].as_array().map(Vec::as_slice).unwrap_or(&[]);
|
||||
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
|
||||
let hit = match target {
|
||||
None => list.first(),
|
||||
Some(t) => {
|
||||
let t = t.trim().trim_start_matches("/file/");
|
||||
let p = |i: &Value| i["path"].as_str().map(String::from);
|
||||
list.iter()
|
||||
.find(|i| {
|
||||
i["id"] == t
|
||||
|| i["name"] == t
|
||||
|| p(i).is_some_and(|p| p == t || canon(&p) == canon(t))
|
||||
})
|
||||
.or_else(|| {
|
||||
list.iter().find(|i| {
|
||||
i["name"]
|
||||
.as_str()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(t))
|
||||
|| p(i)
|
||||
.is_some_and(|p| Path::new(&p).file_name().is_some_and(|f| f == t))
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
if hit.is_some_and(|i| i["open"] == true) {
|
||||
return Err("PaperCraft has this project open; close it or drop --offline".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------- scripts
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Stmt {
|
||||
Call { tool: String, args: Value },
|
||||
Cmd { name: String, args: Vec<String> },
|
||||
}
|
||||
|
||||
/// True when every `{` / `[` outside a string is closed.
|
||||
pub fn balanced(s: &str) -> bool {
|
||||
let (mut depth, mut in_str, mut esc) = (0i32, false, false);
|
||||
for c in s.chars() {
|
||||
if in_str {
|
||||
match (esc, c) {
|
||||
(true, _) => esc = false,
|
||||
(false, '\\') => esc = true,
|
||||
(false, '"') => in_str = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => in_str = true,
|
||||
'{' | '[' => depth += 1,
|
||||
'}' | ']' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
depth <= 0 && !in_str
|
||||
}
|
||||
|
||||
/// Collects lines into statements: a command is one line; a tool call continues over lines
|
||||
/// until its JSON closes. Blank lines and `#` comments between statements are skipped.
|
||||
#[derive(Default)]
|
||||
pub struct Accum {
|
||||
buf: String,
|
||||
start: usize,
|
||||
}
|
||||
|
||||
impl Accum {
|
||||
/// Feed one line (`line_no` counts from 1). Returns a finished statement and its first line.
|
||||
pub fn push(&mut self, line_no: usize, line: &str) -> Option<(usize, String)> {
|
||||
if self.buf.is_empty() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() || t.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
self.start = line_no;
|
||||
self.buf = t.to_string();
|
||||
if t.starts_with(':') {
|
||||
return Some((self.start, std::mem::take(&mut self.buf)));
|
||||
}
|
||||
} else {
|
||||
self.buf.push('\n');
|
||||
self.buf.push_str(line);
|
||||
}
|
||||
balanced(&self.buf).then(|| (self.start, std::mem::take(&mut self.buf)))
|
||||
}
|
||||
|
||||
/// A statement is half-read (the shell shows a continuation prompt).
|
||||
pub fn pending(&self) -> bool {
|
||||
!self.buf.is_empty()
|
||||
}
|
||||
|
||||
/// At end of input: an unfinished statement (it will fail to parse).
|
||||
pub fn finish(&mut self) -> Option<(usize, String)> {
|
||||
(!self.buf.is_empty()).then(|| (self.start, std::mem::take(&mut self.buf)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_statement(s: &str) -> Result<Stmt, String> {
|
||||
let s = s.trim();
|
||||
if let Some(cmd) = s.strip_prefix(':') {
|
||||
let mut w = cmd.split_whitespace().map(String::from);
|
||||
let name = w.next().ok_or("empty command")?;
|
||||
return Ok(Stmt::Cmd {
|
||||
name,
|
||||
args: w.collect(),
|
||||
});
|
||||
}
|
||||
let (tool, rest) = s.split_once(char::is_whitespace).unwrap_or((s, ""));
|
||||
let rest = rest.trim();
|
||||
let args = if rest.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(rest).map_err(|e| format!("arguments are not valid JSON: {e}"))?
|
||||
};
|
||||
Ok(Stmt::Call {
|
||||
tool: tool.into(),
|
||||
args,
|
||||
})
|
||||
}
|
||||
|
||||
/// A whole script: `(first line number, parsed statement)`.
|
||||
pub fn parse_script(text: &str) -> Vec<(usize, Result<Stmt, String>)> {
|
||||
let mut acc = Accum::default();
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if let Some((at, s)) = acc.push(i + 1, line) {
|
||||
out.push((at, parse_statement(&s)));
|
||||
}
|
||||
}
|
||||
if let Some((at, s)) = acc.finish() {
|
||||
out.push((at, parse_statement(&s)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------- images
|
||||
|
||||
const KITTY_CHUNK: usize = 4096;
|
||||
|
||||
/// The escape sequence that shows a PNG inline: iTerm2 / WezTerm (OSC 1337) or kitty; None
|
||||
/// for any other terminal.
|
||||
pub fn inline_image(term_program: &str, term: &str, png: &[u8]) -> Option<String> {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
if ["iTerm.app", "WezTerm"].contains(&term_program) {
|
||||
return Some(format!(
|
||||
"\x1b]1337;File=inline=1;size={}:{b64}\x07\n",
|
||||
png.len()
|
||||
));
|
||||
}
|
||||
if term != "xterm-kitty" {
|
||||
return None;
|
||||
}
|
||||
let chunks: Vec<&[u8]> = b64.as_bytes().chunks(KITTY_CHUNK).collect();
|
||||
let mut s = String::new();
|
||||
for (i, c) in chunks.iter().enumerate() {
|
||||
let more = u8::from(i + 1 < chunks.len());
|
||||
let head = if i == 0 { "a=T,f=100," } else { "" };
|
||||
s += &format!(
|
||||
"\x1b_G{head}m={more};{}\x1b\\",
|
||||
std::str::from_utf8(c).unwrap_or("")
|
||||
);
|
||||
}
|
||||
s.push('\n');
|
||||
Some(s)
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
//! | a value that is not a hex color | a token (PaperCraft colors are hex only) |
|
||||
|
||||
use crate::model::{Color, Document};
|
||||
use crate::variables::{DesignSystem, Kind, PageModes};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -298,6 +299,50 @@ pub fn tokens_css(doc: &Document) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The design system as a `tokens.css` file for a page with `modes`: colors, then numbers (px),
|
||||
/// strings (verbatim) and booleans, each run headed by a `/* group */` comment per variable group.
|
||||
/// The comments are ignored on import, so it still round-trips.
|
||||
pub fn tokens_css_system(ds: &DesignSystem, modes: &PageModes) -> String {
|
||||
let cache = ds.cache_for(modes);
|
||||
let mut vars: Vec<&crate::variables::Variable> = ds
|
||||
.collections
|
||||
.iter()
|
||||
.flat_map(|c| &c.variables)
|
||||
.collect();
|
||||
vars.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let mut out = String::from(":root {\n");
|
||||
for colors in [true, false] {
|
||||
let mut group: Option<&str> = None;
|
||||
for v in vars.iter().filter(|v| (v.kind == Kind::Color) == colors) {
|
||||
let css = crate::variables::css_name(v);
|
||||
let value = match cache.variables.get(&css) {
|
||||
Some(c) => c.to_hex(),
|
||||
None => match cache.tokens.get(&css) {
|
||||
Some(t) => clean_value(t),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let g = crate::variables::group_of(&v.name);
|
||||
if group != Some(g) {
|
||||
if !g.is_empty() {
|
||||
out.push_str(&format!(" /* {} */\n", g.replace("*/", "")));
|
||||
}
|
||||
group = Some(g);
|
||||
}
|
||||
out.push_str(&format!(" --{css}: {value};\n"));
|
||||
}
|
||||
}
|
||||
out.push_str("}\n");
|
||||
for (mode, vars) in &cache.modes {
|
||||
out.push_str(&format!("[data-theme=\"{}\"] {{\n", mode_name(mode)));
|
||||
for (name, color) in vars {
|
||||
out.push_str(&format!(" --{name}: {};\n", color.to_hex()));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A CSS-safe custom-property name: letters, digits, `-` and `_`.
|
||||
pub fn sanitize_name(name: &str) -> String {
|
||||
let name = name.trim().trim_start_matches("--");
|
||||
@@ -534,6 +579,29 @@ pub fn export_package_with(
|
||||
dir: &Path,
|
||||
doc: &Document,
|
||||
meta: &Meta,
|
||||
) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
write_package(dir, doc, meta, tokens_css(doc))
|
||||
}
|
||||
|
||||
/// [`export_package_with`] with the tokens written from the project design system (groups,
|
||||
/// numbers, strings, booleans) as the page `modes` see it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_package_system(
|
||||
dir: &Path,
|
||||
doc: &Document,
|
||||
meta: &Meta,
|
||||
ds: &DesignSystem,
|
||||
modes: &PageModes,
|
||||
) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
write_package(dir, doc, meta, tokens_css_system(ds, modes))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_package(
|
||||
dir: &Path,
|
||||
doc: &Document,
|
||||
meta: &Meta,
|
||||
tokens: String,
|
||||
) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let manifest = serde_json::to_string_pretty(&meta.manifest())
|
||||
@@ -541,7 +609,7 @@ pub fn export_package_with(
|
||||
let mut written = Vec::new();
|
||||
for (name, body) in [
|
||||
(MANIFEST_FILE, manifest),
|
||||
(TOKENS_FILE, tokens_css(doc)),
|
||||
(TOKENS_FILE, tokens),
|
||||
(DESIGN_FILE, design_md(doc)),
|
||||
] {
|
||||
let path = dir.join(name);
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Undoable annotation edits: each call is one history step.
|
||||
|
||||
use super::Editor;
|
||||
use crate::history::Tx;
|
||||
use crate::model::annotation::{Annotation, AnnotationCategory, AnnotationTemplate, builtin_templates};
|
||||
use crate::variables::{Error, PAGE_TEMPLATE_KEY};
|
||||
|
||||
impl Tx<'_> {
|
||||
/// Add an annotation (its id is allocated here). Returns the id.
|
||||
pub fn add_annotation(&mut self, mut a: Annotation) -> u64 {
|
||||
self.snapshot_annotations();
|
||||
a.id = self.doc.alloc_id().0;
|
||||
if a.created == 0 {
|
||||
a.created = crate::project::format::now_ms();
|
||||
}
|
||||
let id = a.id;
|
||||
self.doc.annotations.push(a);
|
||||
id
|
||||
}
|
||||
|
||||
/// Add annotation copies (see `annotation::copies_for`) under fresh ids.
|
||||
pub fn add_annotation_copies(&mut self, copies: Vec<Annotation>) {
|
||||
for a in copies {
|
||||
self.add_annotation(a);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change an annotation in place; `id` and `target` stay as they were unless `f` sets them.
|
||||
pub fn update_annotation(&mut self, id: u64, f: impl FnOnce(&mut Annotation)) -> bool {
|
||||
if !self.doc.annotations.iter().any(|a| a.id == id) {
|
||||
return false;
|
||||
}
|
||||
self.snapshot_annotations();
|
||||
let a = self.doc.annotations.iter_mut().find(|a| a.id == id).unwrap();
|
||||
f(a);
|
||||
a.id = id;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn delete_annotation(&mut self, id: u64) -> bool {
|
||||
if !self.doc.annotations.iter().any(|a| a.id == id) {
|
||||
return false;
|
||||
}
|
||||
self.snapshot_annotations();
|
||||
self.doc.annotations.retain(|a| a.id != id);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn add_annotation(&mut self, a: Annotation) -> u64 {
|
||||
self.edit("Add annotation", |tx| tx.add_annotation(a))
|
||||
}
|
||||
|
||||
/// Update label, body, category, position or anchor of one annotation.
|
||||
pub fn update_annotation(&mut self, id: u64, f: impl FnOnce(&mut Annotation)) -> bool {
|
||||
self.edit("Edit annotation", |tx| tx.update_annotation(id, f))
|
||||
}
|
||||
|
||||
pub fn delete_annotation(&mut self, id: u64) -> bool {
|
||||
self.edit("Delete annotation", |tx| tx.delete_annotation(id))
|
||||
}
|
||||
|
||||
/// Page `page`'s own template override, if set.
|
||||
pub fn page_template_id(&self, page: usize) -> Option<&str> {
|
||||
self.pages[page].modes.get(PAGE_TEMPLATE_KEY).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The template in effect on page `page`: its override, else the project default.
|
||||
pub fn active_template(&self, page: usize) -> AnnotationTemplate {
|
||||
self.design.active_template(self.page_template_id(page))
|
||||
}
|
||||
|
||||
/// Set the project's default template (`None` = Product spec). Undoable.
|
||||
pub fn set_default_template(&mut self, id: Option<&str>) -> Result<(), Error> {
|
||||
if let Some(id) = id
|
||||
&& !self.design.templates().iter().any(|t| t.id == id)
|
||||
{
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
self.edit_design("Set default annotation template", |d| {
|
||||
d.annotation_template = id.map(String::from);
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Override the template on one page (`None` = follow the project default). Undoable.
|
||||
pub fn set_page_template(&mut self, page: usize, id: Option<&str>) -> Result<(), Error> {
|
||||
if let Some(id) = id
|
||||
&& !self.design.templates().iter().any(|t| t.id == id)
|
||||
{
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
self.set_page_template_on(page, id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a template, or replace the one with the same id. Undoable.
|
||||
pub fn upsert_template(&mut self, t: AnnotationTemplate) -> Result<(), Error> {
|
||||
self.edit_design("Edit annotation template", |d| {
|
||||
let mut all = d.templates();
|
||||
match all.iter_mut().find(|x| x.id == t.id) {
|
||||
Some(x) => *x = t,
|
||||
None => all.push(t),
|
||||
}
|
||||
d.annotation_templates = all;
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Delete a template (the last one stays). A default pointing at it goes back to Product spec.
|
||||
pub fn delete_template(&mut self, id: &str) -> Result<(), Error> {
|
||||
self.edit_design("Delete annotation template", |d| {
|
||||
let mut all = d.templates();
|
||||
if all.len() < 2 {
|
||||
return Err(Error::LastMode);
|
||||
}
|
||||
let n = all.len();
|
||||
all.retain(|t| t.id != id);
|
||||
if all.len() == n {
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
d.annotation_templates = all;
|
||||
if d.annotation_template.as_deref() == Some(id) {
|
||||
d.annotation_template = None;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Copy a template under a fresh id ("<name> copy"). Returns the new id. Undoable.
|
||||
pub fn duplicate_template(&mut self, id: &str) -> Result<String, Error> {
|
||||
self.edit_design("Duplicate annotation template", |d| {
|
||||
let mut all = d.templates();
|
||||
let src = all.iter().find(|t| t.id == id).ok_or_else(|| Error::NotFound(id.into()))?;
|
||||
let mut copy = src.clone();
|
||||
let mut n = 1;
|
||||
while all.iter().any(|t| t.id == copy.id) {
|
||||
n += 1;
|
||||
copy.id = format!("{id}-{n}");
|
||||
}
|
||||
copy.name = format!("{} copy", src.name);
|
||||
let new = copy.id.clone();
|
||||
all.push(copy);
|
||||
d.annotation_templates = all;
|
||||
Ok(new)
|
||||
})
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
|
||||
/// Save an edited template together with the category list it was edited against. A changed
|
||||
/// category key is renamed in every template. One undo step per page. Duplicate keys are refused.
|
||||
pub fn save_template(&mut self, t: AnnotationTemplate, cats: Vec<AnnotationCategory>) -> Result<(), Error> {
|
||||
self.edit_design("Edit annotation template", |d| {
|
||||
let old = d.categories();
|
||||
for (i, c) in cats.iter().enumerate() {
|
||||
if cats[..i].iter().any(|o| o.key().eq_ignore_ascii_case(c.key())) {
|
||||
return Err(Error::Duplicate(c.key().into()));
|
||||
}
|
||||
}
|
||||
let renames: Vec<(String, String)> = cats
|
||||
.iter()
|
||||
.filter_map(|c| old.iter().find(|o| o.id == c.id).map(|o| (o.key().to_string(), c.key().to_string())))
|
||||
.filter(|(a, b)| a != b)
|
||||
.collect();
|
||||
let rename = |t: &mut AnnotationTemplate| {
|
||||
for tc in &mut t.categories {
|
||||
if let Some((_, to)) = renames.iter().find(|(from, _)| *from == tc.key) {
|
||||
tc.key.clone_from(to);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut all = d.templates();
|
||||
all.iter_mut().for_each(rename);
|
||||
let mut t = t;
|
||||
rename(&mut t);
|
||||
match all.iter_mut().find(|x| x.id == t.id) {
|
||||
Some(x) => *x = t,
|
||||
None => all.push(t),
|
||||
}
|
||||
d.annotation_templates = all;
|
||||
d.annotation_categories = cats;
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Put a built-in template back to its original content. Errors for a custom template.
|
||||
pub fn reset_template(&mut self, id: &str) -> Result<(), Error> {
|
||||
let original = builtin_templates()
|
||||
.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| Error::NotFound(id.into()))?;
|
||||
self.upsert_template(original)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::{Rect, V2, v2};
|
||||
use crate::model::annotation::{self, Anchor, Side};
|
||||
use crate::model::{Node, NodeId, NodeKind};
|
||||
|
||||
fn frame(e: &mut Editor, r: Rect) -> NodeId {
|
||||
e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, r), None)
|
||||
}
|
||||
|
||||
fn child(e: &mut Editor, parent: NodeId, r: Rect) -> NodeId {
|
||||
e.add_node(Node::new(NodeId(0), NodeKind::Rect, r), Some(parent))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_update_delete_undo_one_step_each() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let base = e.doc.clone();
|
||||
let mut a = Annotation::new(Some(f), "Label", "data");
|
||||
a.position = v2(120.0, 0.0);
|
||||
let id = e.add_annotation(a);
|
||||
let added = e.doc.clone();
|
||||
assert_eq!(e.doc.annotations.len(), 1);
|
||||
assert!(e.update_annotation(id, |a| {
|
||||
a.label = "New".into();
|
||||
a.body = "text".into();
|
||||
a.category = "content".into();
|
||||
a.position = v2(5.0, 6.0);
|
||||
a.anchor = Anchor { side: Side::Left, t: 0.25 };
|
||||
}));
|
||||
let updated = e.doc.clone();
|
||||
assert_eq!(updated.annotations[0].label, "New");
|
||||
assert!(e.delete_annotation(id));
|
||||
assert!(e.doc.annotations.is_empty());
|
||||
assert!(!e.delete_annotation(id), "missing id is a no-op");
|
||||
e.undo();
|
||||
assert_eq!(e.doc, updated);
|
||||
e.undo();
|
||||
assert_eq!(e.doc, added);
|
||||
e.undo();
|
||||
assert_eq!(e.doc, base);
|
||||
e.redo();
|
||||
assert_eq!(e.doc, added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_and_resize_target_keep_anchor_and_card() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
|
||||
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
|
||||
let mut a = Annotation::new(Some(c), "x", "data");
|
||||
a.anchor = Anchor { side: Side::Right, t: 0.5 };
|
||||
a.position = v2(150.0, -10.0);
|
||||
let id = e.add_annotation(a);
|
||||
let a0 = e.doc.annotations[0].clone();
|
||||
assert_eq!(annotation::anchor_world(&e.doc, &a0), v2(210.0, 145.0));
|
||||
assert_eq!(annotation::card_world(&e.doc, &a0), v2(260.0, 110.0));
|
||||
e.edit("move", |tx| tx.node_mut(c).unwrap().rect = Rect::new(30.0, 40.0, 200.0, 100.0));
|
||||
let a1 = e.doc.annotations[0].clone();
|
||||
assert_eq!(a1, a0, "annotation itself untouched");
|
||||
assert_eq!(annotation::anchor_world(&e.doc, &a1), v2(330.0, 190.0));
|
||||
assert_eq!(annotation::card_world(&e.doc, &a1), v2(280.0, 130.0));
|
||||
e.edit("move frame", |tx| tx.node_mut(f).unwrap().rect.x += 1000.0);
|
||||
assert_eq!(annotation::anchor_world(&e.doc, &e.doc.annotations[0]), v2(1330.0, 190.0));
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_target_detaches_and_undo_reattaches() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
|
||||
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
|
||||
let mut a = Annotation::new(Some(c), "x", "data");
|
||||
a.anchor = Anchor { side: Side::Bottom, t: 1.0 };
|
||||
a.position = v2(150.0, 0.0);
|
||||
e.add_annotation(a);
|
||||
let before = e.doc.clone();
|
||||
let (anchor, card) = {
|
||||
let a = &e.doc.annotations[0];
|
||||
(annotation::anchor_world(&e.doc, a), annotation::card_world(&e.doc, a))
|
||||
};
|
||||
e.edit("Delete", |tx| tx.remove(f));
|
||||
let a = &e.doc.annotations[0];
|
||||
assert!(a.detached && a.target.is_none());
|
||||
assert_eq!(a.point, Some(anchor));
|
||||
assert_eq!(annotation::anchor_world(&e.doc, a), anchor);
|
||||
assert_eq!(annotation::card_world(&e.doc, a), card);
|
||||
e.undo();
|
||||
assert_eq!(e.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_position_sits_right_of_frame_and_stacks() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
|
||||
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
|
||||
let p1 = annotation::auto_position(&e.doc, c);
|
||||
let mut a = Annotation::new(Some(c), "1", "rule");
|
||||
a.position = p1;
|
||||
e.add_annotation(a);
|
||||
let w1 = annotation::card_world(&e.doc, &e.doc.annotations[0]);
|
||||
assert_eq!(w1, v2(524.0, 120.0));
|
||||
let p2 = annotation::auto_position(&e.doc, c);
|
||||
let w2 = v2(110.0, 120.0) + p2;
|
||||
assert_eq!(w2.x, w1.x);
|
||||
assert!(w2.y >= w1.y + annotation::CARD_H, "stacked below, no overlap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbering_is_per_frame_in_reading_order() {
|
||||
let mut e = Editor::default();
|
||||
let f1 = frame(&mut e, Rect::new(0.0, 0.0, 300.0, 300.0));
|
||||
let f2 = frame(&mut e, Rect::new(500.0, 0.0, 300.0, 300.0));
|
||||
let low = child(&mut e, f1, Rect::new(10.0, 200.0, 20.0, 20.0));
|
||||
let right = child(&mut e, f1, Rect::new(200.0, 10.0, 20.0, 20.0));
|
||||
let left = child(&mut e, f1, Rect::new(10.0, 10.0, 20.0, 20.0));
|
||||
let other = child(&mut e, f2, Rect::new(10.0, 10.0, 20.0, 20.0));
|
||||
let mut ids = Vec::new();
|
||||
for t in [Some(low), Some(right), Some(left), Some(other), None] {
|
||||
ids.push(e.add_annotation(Annotation::new(t, "n", "data")));
|
||||
}
|
||||
let n = annotation::numbers(&e.doc);
|
||||
assert_eq!(n[&ids[2]], (Some(f1), 1));
|
||||
assert_eq!(n[&ids[1]], (Some(f1), 2));
|
||||
assert_eq!(n[&ids[0]], (Some(f1), 3));
|
||||
assert_eq!(n[&ids[3]], (Some(f2), 1));
|
||||
assert_eq!(n[&ids[4]], (None, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_of_a_frame_copies_its_annotations() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(0.0, 0.0, 200.0, 200.0));
|
||||
let c = child(&mut e, f, Rect::new(10.0, 10.0, 20.0, 20.0));
|
||||
e.add_annotation(Annotation::new(Some(c), "on child", "data"));
|
||||
e.add_annotation(Annotation::new(Some(f), "on frame", "content"));
|
||||
e.add_annotation(Annotation::new(None, "loose", "content"));
|
||||
e.select(vec![f]);
|
||||
e.copy_internal();
|
||||
e.paste(V2::ZERO);
|
||||
assert_eq!(e.doc.annotations.len(), 5);
|
||||
let copy = e.selection[0];
|
||||
let copy_child = e.doc.get(copy).unwrap().children[0];
|
||||
let mut ids: Vec<u64> = e.doc.annotations.iter().map(|a| a.id).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 5, "fresh ids");
|
||||
let on = |t| e.doc.annotations.iter().filter(|a| a.target == Some(t)).count();
|
||||
assert_eq!((on(f), on(c), on(copy), on(copy_child)), (1, 1, 1, 1));
|
||||
e.undo();
|
||||
assert_eq!(e.doc.annotations.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_snapshot_restores_annotations_in_one_undo() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
e.add_annotation(Annotation::new(Some(f), "old", "data"));
|
||||
let snap = e.doc.clone();
|
||||
e.delete_annotation(snap.annotations[0].id);
|
||||
e.add_annotation(Annotation::new(Some(f), "agent", "rule"));
|
||||
let changed = e.doc.clone();
|
||||
e.restore_snapshot(&snap);
|
||||
assert_eq!(e.doc.annotations, snap.annotations, "target kept, not detached");
|
||||
e.undo();
|
||||
assert_eq!(e.doc.annotations, changed.annotations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_to_snapshot_without_annotations_removes_them_and_undo_restores() {
|
||||
let mut e = Editor::default();
|
||||
let f = frame(&mut e, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let snap = e.doc.clone();
|
||||
e.add_annotation(Annotation::new(Some(f), "agent", "rule"));
|
||||
e.restore_snapshot(&snap);
|
||||
assert!(e.doc.annotations.is_empty());
|
||||
e.undo();
|
||||
assert_eq!(e.doc.annotations.len(), 1);
|
||||
assert_eq!(e.doc.annotations[0].target, Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_template_renames_category_key_everywhere_in_one_undo() {
|
||||
let mut e = Editor::default();
|
||||
let mut cats = e.design.categories();
|
||||
cats[0].key = "policy".into();
|
||||
let mut t = e.design.template_by_ref("product-spec").unwrap();
|
||||
t.name = "Spec".into();
|
||||
let before = e.design.clone();
|
||||
let undo_len = e.history.undo_len();
|
||||
e.save_template(t, cats.clone()).unwrap();
|
||||
assert_eq!(e.history.undo_len(), undo_len + 1);
|
||||
for t in e.design.templates() {
|
||||
assert!(t.categories.iter().all(|c| c.key != "rule"), "{}", t.name);
|
||||
}
|
||||
assert!(e.active_template(0).enabled("policy").is_some());
|
||||
assert!(e.design.category_by_key("policy").is_some());
|
||||
let mut dup = cats.clone();
|
||||
dup[1].key = "POLICY".into();
|
||||
let t = e.design.template_by_ref("minimal").unwrap();
|
||||
let now = e.design.clone();
|
||||
assert!(e.save_template(t, dup).is_err());
|
||||
assert_eq!(e.design, now);
|
||||
e.undo();
|
||||
assert_eq!(e.design, before);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,13 @@ pub enum UiEvent {
|
||||
TogglePalette,
|
||||
Rename(NodeId),
|
||||
ToggleUi,
|
||||
ToggleChat,
|
||||
ToggleHelp,
|
||||
ToggleGrid,
|
||||
ToggleAnnotations,
|
||||
OpenVariables,
|
||||
/// Open the New annotation popover on this layer.
|
||||
Annotate(NodeId),
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
|
||||