Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33a0e265b8 | |||
| 45f197250b | |||
| 13bc2d582e |
@@ -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.5.0"
|
||||
version = "0.6.0"
|
||||
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.5.0"
|
||||
version = "0.6.0"
|
||||
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]
|
||||
|
||||
@@ -76,6 +76,14 @@ 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-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.
|
||||
|
||||
@@ -136,6 +136,16 @@ EOF
|
||||
<sub><code>preview.png</code> from an offline shell script, with no app running.</sub>
|
||||
</p>
|
||||
|
||||
### Chat
|
||||
|
||||
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code or Codex through their CLIs with PaperCraft's MCP server, or the Anthropic API with your own key, and it shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
|
||||
|
||||
<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>
|
||||
|
||||
### 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.
|
||||
@@ -212,6 +222,7 @@ The scenario tests drive the editor with synthetic pointer and keyboard events;
|
||||
| ⌘⌥⇧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) |
|
||||
|
||||
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.
|
||||
|
||||
@@ -226,7 +237,7 @@ Draw a frame around existing shapes and it adopts them; dragging a layer into or
|
||||
* **Variables:** gradients keep fixed colors; there are no text styles and no component variants or properties.
|
||||
* **Booleans are destructive:** the result is a new vector path with flattened curves.
|
||||
* **`write_html`** ignores margin, tables and transforms (it reports them as `ignoredStyles`) and does not fetch remote images; pass a local path or a `data:` URI.
|
||||
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. There is no in-app prompt box; agents run outside and connect over MCP or the CLI.
|
||||
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. The in-app Chat mode runs Claude Code, Codex or your own Anthropic key; other agents run outside and 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
PaperCraft is an early alpha: a local-first infinite-canvas design app that agents can drive. This
|
||||
file lists what is still missing, in priority order, with an honest status. Done items are checked
|
||||
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 7, 8, 12, 15, 23, 25, 32, 34, 39, 40, 45, 46 and section 6 were updated 2026-10-21. Milestone history is in
|
||||
against `src/mcp/tools.rs` and the UI code (2026-10-12); rows 7, 8, 12, 15, 23, 25, 32, 34, 39, 40, 44, 45, 46 and section 6 were updated 2026-10-21. 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**.
|
||||
@@ -84,5 +84,5 @@ 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. |
|
||||
|
||||
@@ -8,6 +8,8 @@ PaperCraft exposes one set of 61 tools three ways. They all run the same code in
|
||||
| MCP over stdio, `papercraft-mcp` | the client only starts stdio servers | for calls; `tools/list` works offline |
|
||||
| Shell, `papercraft-cli` | the agent has a terminal and you want no tool schemas in its context | for `call`; `list`, `schema` and `icons` work offline |
|
||||
|
||||
If you would rather talk to the agent inside the app, Chat mode (⌘J, or `Design | Chat` in the top bar) is the in-app alternative. It runs Claude Code or Codex through their CLIs against the same MCP server, or the Anthropic API with your own key, and keeps the conversation next to the canvas, with the same runs, review and revert. The setup and the thread are in [chat.md](chat.md). The rest of this guide covers driving the app from your own terminal or agent client.
|
||||
|
||||
## 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/`).
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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 kinds of backend.
|
||||
|
||||
**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.
|
||||
|
||||
**API endpoints** call a model over HTTPS with your own key. Settings → Chat holds a list of endpoints, each with a name, a format, a base URL, a model and an optional key. The app's tool list is sent as the API tools, and every tool call runs through the same code path as an MCP call on the UI thread. The loop continues until the model ends its turn, for up to 40 tool rounds per message.
|
||||
|
||||
* **Format**: `Anthropic` is the Messages API (`POST {base}/v1/messages`, `x-api-key`). `OpenAI` is Chat Completions (`POST {base}/chat/completions`, `Authorization: Bearer`, streaming `choices[].delta`, tools as `{"type":"function",...}`, tool results as `tool` messages). Both give the same thread: text, tool cards, approvals, Stop, the usage line.
|
||||
* **Base URL and model** are free text, for example `https://api.anthropic.com`, any Anthropic-compatible URL, or `http://127.0.0.1:11434/v1`. The key is optional, so local servers need none.
|
||||
* A default endpoint named `Anthropic` (`https://api.anthropic.com`, `claude-opus-5-5`) exists from the start, so the first run needs only a key.
|
||||
* The agent picker in the chat header lists Claude Code, Codex, then every endpoint by name. Each chat remembers its endpoint.
|
||||
* **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
|
||||
|
||||
Chat looks for `claude` and `codex` on `PATH`. A missing CLI is not an error: its row says `Not found` and shows the install command:
|
||||
|
||||
* Claude Code: `npm install -g @anthropic-ai/claude-code`
|
||||
* Codex: `npm install -g @openai/codex`
|
||||
|
||||
Install one of them, or add a key, then press Detect again on the Chat page. Settings → Chat shows each CLI found with its version.
|
||||
|
||||
The Chat page also holds:
|
||||
|
||||
* **Paths**: an override for the `claude` and `codex` binaries, for when they are not on `PATH` or you want a specific install. Empty means the first one on `PATH`.
|
||||
* **Default backend**: the backend a new chat starts with. The agent picker in the chat header changes it per chat.
|
||||
* **API endpoints**: add, edit and remove endpoints (name, format, base URL, model) and set the default one. Each has a key field with Save and Remove. Keys are stored in the macOS Keychain, Windows Credential Manager or the Linux Secret Service, under the service name `PaperCraft` and the user `api-key:<base URL>`. An older single key (`anthropic-api-key`) is still read for `api.anthropic.com` and moves to the new entry the next time you save it. Removing a key makes that endpoint stop until a key is saved again (for `api.anthropic.com`; other endpoints work without one).
|
||||
* **Defaults for new chats**: the backend, the endpoint, and the effort for `api.anthropic.com`.
|
||||
|
||||
Settings from before endpoints existed (`api_model`) move into the default endpoint on first load. These defaults and the endpoint list (never the keys) are saved in `chat_settings.json` in your config folder (`~/Library/Application Support/papercraft/` on macOS). They are not part of any project.
|
||||
|
||||
## The thread
|
||||
|
||||
The header has the chat title (rename), the chat list (new chat, switch, delete), the agent picker (for example `Claude Code · Opus 5.5`) and the context meter. The meter shows the tokens in the context at the last model call against a 200k window, and turns amber past 75%. It is hidden for Codex, which reports no per-call usage.
|
||||
|
||||
Replies are markdown: paragraphs, lists, code blocks with a copy button, inline code and links. While a turn runs, the reply streams in.
|
||||
|
||||
* **Thinking**: when the backend reports reasoning, a row shows "Thinking…" while it runs and collapses to a summary when it ends. Expand it to read the text.
|
||||
* **Tool cards**: one card per tool call with the verb and target (for example "Drawing layout · Card"), a spinner while it runs, and a failed state with the error. Args and result are collapsible. `get_screenshot` results show a thumbnail.
|
||||
* **Step groups**: consecutive tool calls in one reply fold into one line ("Built the card · 5 steps") that expands to the cards.
|
||||
* **Plan**: when the agent keeps a todo list (Claude Code's `TodoWrite`), the latest list is pinned above the composer with a "done of total" count.
|
||||
* **Approval**: on the API backend, the destructive tools (`delete_pages`, `disable_library`, `import_design_system`, and `delete_nodes` on more than 20 layers) wait for Approve or Deny before they run. Deny returns an error to the model, which can then carry on. The CLI backends do not show this card. They use the CLI's own permission rules, and in non-interactive mode a tool that needs permission is denied. The papercraft tools are pre-allowed on both CLIs.
|
||||
* **Checkpoint**: when an agent run starts, the thread shows "Checkpoint · before run N". "Restore to here" reverts that run and every run after it, through the same revert as the Agent view.
|
||||
* **Run card**: when a run ends, "Agent finished, N changes" with Review, Revert and Accept. These are the same actions as the run review on the canvas.
|
||||
* **Errors**: a failed turn shows a red row with Retry. A missing CLI or key shows a setup row instead.
|
||||
* **Canvas follow**: when a tool call targets a node, the canvas pans to it, unless you moved the camera in the last 10 seconds.
|
||||
|
||||
## Composer and attachments
|
||||
|
||||
Type in the composer, ⏎ sends and ⇧⏎ adds a line. The empty state offers suggestions built from the project, such as "Design a sign-in screen using this project's variables" or "Lint this page and fix warnings".
|
||||
|
||||
* **Selection**: the `@ Selection` chip adds the current selection to the message (also ⌘L). It is sent as a short line with the layer name and keys.
|
||||
* **Images**: the image button opens a file picker. You can also paste a clipboard image or drop image files on the column (PNG, JPEG, GIF, WebP).
|
||||
|
||||
For the API backend, images are sent as image blocks, so the model sees them. For the CLIs, each image is saved under `.papercraft-chat-tmp/` in the project and its path is added to the prompt for the agent to read.
|
||||
|
||||
While a turn runs, the send button becomes **Stop**. Stop asks the backend to interrupt: the CLIs get an interrupt, or SIGINT, then a kill after 3 seconds. Every backend ends within 3 seconds. Tools that already ran stay applied, and the partial run can be reviewed or reverted like any other.
|
||||
|
||||
## Storage
|
||||
|
||||
A chat is one file in the project: `chats/<id>.json`, holding the title, the backend (kind, model, effort, CLI session id) and the messages with their tool calls, usage and run ids. The API backend also keeps `chats/<id>.api.json`, the raw messages array (Messages format with thinking blocks, or Chat Completions format, whichever the endpoint speaks), so a follow-up replays exactly. The chat records its endpoint id. Deleting a chat asks first and removes both files, and its saved images.
|
||||
|
||||
Projects without a `chats/` folder load unchanged. The newest chat opens when you enter Chat mode.
|
||||
|
||||
## Privacy
|
||||
|
||||
PaperCraft never writes an API key into a project file, a chat or a log. The key lives in the OS credential store. The CLIs run with your own logins and do not get the API keys from the environment: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` and `CODEX_API_KEY` are removed from the CLI's environment, so Claude Code and Codex bill your account, not a key you set for PaperCraft. The MCP server listens on loopback only, as it does for any other client.
|
||||
|
||||
Prompts, project content and tool results go to whichever backend you chose: the CLI's model service, or the base URL of the endpoint you picked. Nothing goes to any other service. If a server echoes your key in an error message, the app hides it before showing the message.
|
||||
|
||||
## 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.
|
||||
* The CLI backends need the CLI installed and logged in. Chat does not install or log in for you.
|
||||
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 703 KiB |
|
Before Width: | Height: | Size: 619 KiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 593 KiB After Width: | Height: | Size: 596 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: 624 KiB After Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 824 KiB After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 683 KiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 872 KiB After Width: | Height: | Size: 864 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
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).
|
||||
@@ -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}");
|
||||
@@ -1570,7 +1583,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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => std::env::split_paths(&std::env::var_os("PATH")?).map(|d| d.join("claude")).find(|p| p.is_file())?,
|
||||
};
|
||||
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);
|
||||
}
|
||||
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); }
|
||||
cmd
|
||||
}
|
||||
|
||||
pub fn detect(binary: Option<&Path>) -> Option<(PathBuf, String)> {
|
||||
let candidates = match binary {
|
||||
Some(path) => vec![path.to_path_buf()],
|
||||
None => std::env::split_paths(&std::env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(if cfg!(windows) { "codex.exe" } else { "codex" })).collect(),
|
||||
};
|
||||
candidates.into_iter().find_map(|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 mut command = Command::new(cfg.binary.as_deref().unwrap_or(Path::new("codex")));
|
||||
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,128 @@
|
||||
//! 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 anthropic;
|
||||
pub mod claude_code;
|
||||
pub mod codex;
|
||||
pub mod endpoint;
|
||||
pub mod events;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod openai;
|
||||
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";
|
||||
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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[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,201 @@
|
||||
//! 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()),
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub enum UiEvent {
|
||||
TogglePalette,
|
||||
Rename(NodeId),
|
||||
ToggleUi,
|
||||
ToggleChat,
|
||||
ToggleHelp,
|
||||
ToggleGrid,
|
||||
OpenVariables,
|
||||
|
||||
@@ -250,6 +250,8 @@ pub struct Editor {
|
||||
pub project_stamp: u64,
|
||||
/// Fit the camera to the content once the real viewport size is known.
|
||||
pub pending_fit: bool,
|
||||
/// No window: `viewport` is a stand-in, so cameras are not saved (the app fits on open).
|
||||
pub headless: bool,
|
||||
/// Pages of the open project; the active one's content is `doc`/`history`/`camera`.
|
||||
pub pages: Vec<PageSlot>,
|
||||
pub active_page: usize,
|
||||
@@ -343,6 +345,7 @@ impl Editor {
|
||||
doc_stamp: next_stamp(),
|
||||
project_stamp: next_stamp(),
|
||||
pending_fit: false,
|
||||
headless: false,
|
||||
pages: vec![PageSlot::new(&name)],
|
||||
active_page: 0,
|
||||
page_groups: BTreeMap::new(),
|
||||
@@ -493,6 +496,7 @@ impl Editor {
|
||||
pub fn load(&mut self, doc: Document, camera: Option<Camera>, path: Option<PathBuf>) {
|
||||
*self = Editor {
|
||||
viewport: self.viewport,
|
||||
headless: self.headless,
|
||||
ink_width: self.ink_width,
|
||||
ink_smoothing: self.ink_smoothing,
|
||||
ink_color: self.ink_color,
|
||||
@@ -2021,6 +2025,7 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
A::ToggleUi => self.ui_events.push(commands::UiEvent::ToggleUi),
|
||||
A::ToggleChat => self.ui_events.push(commands::UiEvent::ToggleChat),
|
||||
A::ToggleHelp => self.ui_events.push(commands::UiEvent::ToggleHelp),
|
||||
A::ToggleGrid => self.ui_events.push(commands::UiEvent::ToggleGrid),
|
||||
A::OpenVariables => self.ui_events.push(commands::UiEvent::OpenVariables),
|
||||
|
||||
@@ -421,6 +421,7 @@ impl Editor {
|
||||
let s = slot.stash.as_ref().expect("inactive page has a stash");
|
||||
(s.doc.clone(), (!s.needs_fit).then_some(s.camera))
|
||||
};
|
||||
let camera = camera.filter(|_| !self.headless);
|
||||
PageData {
|
||||
id: slot.id.clone(),
|
||||
name: slot.name.clone(),
|
||||
|
||||
@@ -57,6 +57,7 @@ pub enum Action {
|
||||
CommandPalette,
|
||||
Rename,
|
||||
ToggleUi,
|
||||
ToggleChat,
|
||||
ToggleHelp,
|
||||
ToggleGrid,
|
||||
OpenVariables,
|
||||
@@ -158,6 +159,7 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
|
||||
(Char('k'), CMD, A::CommandPalette, "Command palette"),
|
||||
(Char('p'), CMD_ALT, A::ExportPdf, "Export PDF"),
|
||||
(Char('c'), CMD_SHIFT, A::CopyAsPng, "Copy as PNG"),
|
||||
(Char('j'), CMD, A::ToggleChat, "Design / Chat mode"),
|
||||
(Char('c'), CMD_ALT_SHIFT, A::CopyAsSvg, "Copy as SVG"),
|
||||
(Char('/'), CMD_SHIFT, A::CommandPalette, "Command palette"),
|
||||
(Char('r'), CMD, A::Rename, "Rename layer"),
|
||||
@@ -424,6 +426,7 @@ pub fn category(a: Action) -> &'static str {
|
||||
| A::ToggleGrid
|
||||
| A::OpenVariables
|
||||
| A::ToggleUi
|
||||
| A::ToggleChat
|
||||
| A::ToggleHelp
|
||||
| A::NextPage
|
||||
| A::PrevPage => "View",
|
||||
@@ -606,6 +609,7 @@ pub fn menus() -> Vec<(&'static str, Vec<MenuEntry>)> {
|
||||
I("Snapping", A::ToggleSnapping),
|
||||
I("Dot Grid", A::ToggleGrid),
|
||||
I("Show/Hide UI", A::ToggleUi),
|
||||
I("Design / Chat Mode", A::ToggleChat),
|
||||
I("Keyboard Shortcuts", A::ToggleHelp),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod chat;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod cli;
|
||||
pub mod connector;
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::editor::Editor;
|
||||
use crate::model::{Document, Node, NodeId, Source};
|
||||
use crate::project::{Project, Store};
|
||||
use crate::variables::{self, Collection, EnabledLibrary, PageModes, Value, stable_id};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cell::Cell;
|
||||
@@ -19,6 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
pub const LIBRARY_FILE: &str = "library.json";
|
||||
pub const LIBRARIES_DIR: &str = "libraries";
|
||||
/// Longest side of a component preview, in px.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PREVIEW_PX: f64 = 256.0;
|
||||
/// Node ids stay below 2^53 so they survive a JSON round trip in any reader.
|
||||
const ID_MASK: u64 = (1 << 53) - 1;
|
||||
|
||||
@@ -18,7 +18,7 @@ USAGE:
|
||||
papercraft PROJECT_DIR open a project folder
|
||||
papercraft FILE.papercraft open a PaperCraft document (not autosaved)
|
||||
papercraft --demo open the app with a sample board
|
||||
papercraft --screenshot OUT.png [--home | PROJECT|FILE|--demo] [--size WxH] [--scale S]
|
||||
papercraft --screenshot OUT.png [--home | PROJECT|FILE|--demo] [--size WxH] [--scale S] [--chat]
|
||||
render the full UI headlessly to a PNG
|
||||
papercraft --export-png OUT.png PROJECT|FILE [--page N] [--scale S] [--transparent]
|
||||
papercraft --export-svg OUT.svg PROJECT|FILE [--page N]
|
||||
@@ -126,6 +126,20 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
if let Some(out) = value("--screenshot") {
|
||||
let mut ed = load_editor(input, flag("--demo"))?;
|
||||
let mut st = UiState::default();
|
||||
if flag("--chat") {
|
||||
st.chat_mode = true;
|
||||
st.project_dir = input.map(std::path::PathBuf::from).filter(|p| p.is_dir());
|
||||
// As if the project was already seen, so `draw` keeps Chat mode on.
|
||||
st.chat.project.clone_from(&st.project_dir);
|
||||
st.chat.picker_open = flag("--chat-picker");
|
||||
if let Some(dir) = &st.project_dir {
|
||||
st.runs = papercraft::runs::list(dir);
|
||||
st.runs_key.clone_from(&st.project_dir);
|
||||
}
|
||||
}
|
||||
if flag("--dark") {
|
||||
papercraft::theme::apply_choice(&egui::Context::default(), papercraft::theme::Choice::Dark);
|
||||
}
|
||||
if flag("--home") {
|
||||
let store = Store::open(Store::default_root()).map_err(|e| e.to_string())?;
|
||||
st.home = Some(HomeView::load(&store, papercraft::project::now_ms()));
|
||||
|
||||
@@ -58,8 +58,10 @@ pub struct SessionHost {
|
||||
|
||||
impl SessionHost {
|
||||
pub fn new(store: Store) -> SessionHost {
|
||||
let mut ed = Editor::default();
|
||||
ed.headless = true;
|
||||
SessionHost {
|
||||
ed: Editor::default(),
|
||||
ed,
|
||||
session: Session::new(store),
|
||||
gpu: None,
|
||||
gpu_tried: false,
|
||||
@@ -3075,6 +3077,9 @@ fn create_shape(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
};
|
||||
let sides = a.num("sides").map(|s| s.clamp(3.0, 64.0) as u32);
|
||||
let txt = a.str("text").unwrap_or("").to_string();
|
||||
// A top-level shape with no position goes where a new artboard would, not on top of
|
||||
// the first one at the origin.
|
||||
let free = parent.is_none() && a.num("x").is_none() && a.num("y").is_none();
|
||||
let (x, y) = (a.num("x").unwrap_or(0.0), a.num("y").unwrap_or(0.0));
|
||||
let (w, h) = (a.num("width"), a.num("height"));
|
||||
let route = match a.str("route") {
|
||||
@@ -3092,6 +3097,12 @@ fn create_shape(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let (id, ignored) = ed.with_page(page, |ed| {
|
||||
ed.edit("Add shape", |tx| -> Result<(NodeId, Vec<String>), String> {
|
||||
let o = tx.doc().parent_origin(parent);
|
||||
let (x, y) = if free {
|
||||
let p = free_spot(tx.doc(), w.unwrap_or(160.0));
|
||||
(p.x, p.y)
|
||||
} else {
|
||||
(x, y)
|
||||
};
|
||||
let rect = Rect::new(x, y, w.unwrap_or(160.0), h.unwrap_or(100.0));
|
||||
let kind = match ty.as_str() {
|
||||
"rectangle" | "rect" => NodeKind::Rect,
|
||||
|
||||
@@ -124,6 +124,28 @@ pub fn revert(project: &Path, ed: &mut Editor, run: &mut Run) -> Result<(), Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The run `id` and every later run of the project (all chats, all pages) that can still be
|
||||
/// reverted, newest first. `runs` is in `list` order (newest first).
|
||||
pub fn runs_from(runs: &[Run], id: &str) -> Vec<Run> {
|
||||
let Some(from) = runs.iter().find(|r| r.id == id) else { return Vec::new() };
|
||||
let key = (from.started, from.number);
|
||||
runs.iter()
|
||||
.filter(|r| (r.started, r.number) >= key && ![RunState::Reverted, RunState::Running].contains(&r.state))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// "Restore to here": reverts `runs` (newest first, see [`runs_from`]) one by one, so each page
|
||||
/// ends as it was before the oldest of them. Stops at the first failure. Returns how many were reverted.
|
||||
pub fn revert_all(project: &Path, ed: &mut Editor, runs: Vec<Run>) -> Result<usize, String> {
|
||||
let mut done = 0;
|
||||
for mut run in runs {
|
||||
revert(project, ed, &mut run).map_err(|e| format!("run {}: {e}", run.number))?;
|
||||
done += 1;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// Undoes one row of a run's diff on `page_id`, as one undo step on that page. A modified node gets
|
||||
/// its snapshot properties back (children stay as they are now), a removed subtree returns under its
|
||||
/// old parent at its old index (page root when the parent is gone), an added subtree is deleted.
|
||||
@@ -359,6 +381,12 @@ impl Tracker {
|
||||
r
|
||||
}
|
||||
|
||||
/// Closes the open run now (a chat turn is over), the way `finish_working_on_nodes` does,
|
||||
/// instead of waiting `RUN_IDLE_SECS`.
|
||||
pub fn finish(&mut self, now: u64, ed: &Editor) {
|
||||
self.end(now, Some(ed), None);
|
||||
}
|
||||
|
||||
/// Per frame: closes the run after `RUN_IDLE_SECS` idle or when the user switched project.
|
||||
pub fn tick(&mut self, now: u64, dir: Option<&Path>, ed: &Editor) {
|
||||
self.sync_project(now, dir);
|
||||
@@ -700,6 +728,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_to_here_reverts_this_run_and_every_later_one_across_pages() {
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Node, NodeKind};
|
||||
|
||||
let dir = temp();
|
||||
let mut ed = Editor::default();
|
||||
ed.add_page_in_background(Some("Second"));
|
||||
let add = |ed: &mut Editor, page: usize, name: &str| {
|
||||
ed.switch_page(page);
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
n.name = name.into();
|
||||
ed.add_node(n, None);
|
||||
};
|
||||
// Run 1 touches page 0, run 2 page 1, run 3 page 0 again; each snapshots before it edits.
|
||||
let mut runs = Vec::new();
|
||||
let originals: Vec<_> = (0..2).map(|i| ed.page_data(i).doc.nodes.len()).collect();
|
||||
for (n, page) in [(1, 0), (2, 1), (3, 0)] {
|
||||
let mut r = run(n);
|
||||
r.state = RunState::Review;
|
||||
r.pages.push(snapshot_page(&dir, &ed, page));
|
||||
save(&dir, &r).unwrap();
|
||||
add(&mut ed, page, &format!("run{n}"));
|
||||
runs.push(r);
|
||||
}
|
||||
runs.reverse();
|
||||
let picked = runs_from(&runs, "r2");
|
||||
assert_eq!(picked.iter().map(|r| r.number).collect::<Vec<_>>(), [3, 2], "this run and the later ones, newest first");
|
||||
assert_eq!(revert_all(&dir, &mut ed, picked).unwrap(), 2);
|
||||
assert_eq!(ed.page_data(1).doc.nodes.len(), originals[1], "page 1 is back before run 2");
|
||||
assert_eq!(ed.page_data(0).doc.nodes.len(), originals[0] + 1, "run 1 stays");
|
||||
let states: Vec<_> = list(&dir).iter().map(|r| (r.number, r.state)).collect();
|
||||
assert_eq!(states, [(3, RunState::Reverted), (2, RunState::Reverted), (1, RunState::Review)]);
|
||||
assert!(runs_from(&list(&dir), "r2").is_empty(), "already reverted runs are not offered again");
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_one_change_leaves_the_others_and_is_one_undo() {
|
||||
use crate::geom::Rect;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! A small markdown renderer for chat replies: paragraphs, headings, bullet and numbered lists,
|
||||
//! fenced code blocks (with a copy button), bold, italic, inline code and links. Unclosed markers
|
||||
//! stay literal, so half-streamed text never flickers into a different shape.
|
||||
|
||||
use crate::theme;
|
||||
use egui::text::{LayoutJob, TextFormat};
|
||||
use egui::{Align, FontFamily, FontId, Id, RichText, Ui};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Span {
|
||||
pub text: String,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub code: bool,
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Block {
|
||||
Para(Vec<Span>),
|
||||
Heading(Vec<Span>),
|
||||
/// `start` is Some for numbered lists.
|
||||
List { start: Option<usize>, items: Vec<Vec<Span>> },
|
||||
Code { lang: String, text: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn plain(text: &str) -> Span {
|
||||
Span { text: text.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Position of `pat` in `chars` at or after `from`.
|
||||
fn find(chars: &[char], from: usize, pat: &str) -> Option<usize> {
|
||||
let p: Vec<char> = pat.chars().collect();
|
||||
(from..chars.len().saturating_sub(p.len() - 1)).find(|&i| chars[i..i + p.len()] == p[..])
|
||||
}
|
||||
|
||||
pub fn inline(s: &str) -> Vec<Span> {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
let (mut out, mut buf) = (Vec::<Span>::new(), String::new());
|
||||
let (mut bold, mut italic) = (false, false);
|
||||
let mut i = 0;
|
||||
// Once a `[` finds no `](...)` after it, no later `[` can: stop scanning (keeps this linear).
|
||||
let mut no_more_links = false;
|
||||
let flush = |out: &mut Vec<Span>, buf: &mut String, bold, italic| {
|
||||
if !buf.is_empty() {
|
||||
out.push(Span { text: std::mem::take(buf), bold, italic, ..Default::default() });
|
||||
}
|
||||
};
|
||||
while i < c.len() {
|
||||
let rest_has = |pat: &str| find(&c, i + pat.chars().count(), pat).is_some();
|
||||
match c[i] {
|
||||
'`' if rest_has("`") => {
|
||||
flush(&mut out, &mut buf, bold, italic);
|
||||
let end = find(&c, i + 1, "`").expect("checked");
|
||||
out.push(Span { text: c[i + 1..end].iter().collect(), code: true, ..Default::default() });
|
||||
i = end + 1;
|
||||
}
|
||||
'*' if c.get(i + 1) == Some(&'*') && (bold || rest_has("**")) => {
|
||||
flush(&mut out, &mut buf, bold, italic);
|
||||
bold = !bold;
|
||||
i += 2;
|
||||
}
|
||||
'*' if c.get(i + 1) != Some(&'*') && (italic || rest_has("*")) => {
|
||||
flush(&mut out, &mut buf, bold, italic);
|
||||
italic = !italic;
|
||||
i += 1;
|
||||
}
|
||||
'[' => {
|
||||
let link = if no_more_links { None } else { find(&c, i + 1, "](").and_then(|m| Some((m, find(&c, m + 2, ")")?))) };
|
||||
no_more_links |= link.is_none();
|
||||
match link {
|
||||
Some((m, end)) => {
|
||||
flush(&mut out, &mut buf, bold, italic);
|
||||
out.push(Span {
|
||||
text: c[i + 1..m].iter().collect(),
|
||||
bold,
|
||||
italic,
|
||||
link: Some(c[m + 2..end].iter().collect()),
|
||||
..Default::default()
|
||||
});
|
||||
i = end + 1;
|
||||
}
|
||||
None => {
|
||||
buf.push('[');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
ch => {
|
||||
buf.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut out, &mut buf, bold, italic);
|
||||
out
|
||||
}
|
||||
|
||||
/// `- x`, `* x`, `+ x` -> (None, "x"); `3. x` -> (Some(3), "x").
|
||||
fn list_item(line: &str) -> Option<(Option<usize>, &str)> {
|
||||
let t = line.trim_start();
|
||||
if let Some(r) = ["- ", "* ", "+ "].iter().find_map(|m| t.strip_prefix(m)) {
|
||||
return Some((None, r));
|
||||
}
|
||||
let digits = t.chars().take_while(char::is_ascii_digit).count();
|
||||
let r = t[digits..].strip_prefix(". ")?;
|
||||
(digits > 0).then(|| (t[..digits].parse().ok(), r))
|
||||
}
|
||||
|
||||
pub fn parse(src: &str) -> Vec<Block> {
|
||||
let mut out: Vec<Block> = Vec::new();
|
||||
let mut para: Vec<&str> = Vec::new();
|
||||
let mut lines = src.lines();
|
||||
let end_para = |out: &mut Vec<Block>, para: &mut Vec<&str>| {
|
||||
if !para.is_empty() {
|
||||
out.push(Block::Para(inline(¶.join(" "))));
|
||||
para.clear();
|
||||
}
|
||||
};
|
||||
while let Some(line) = lines.next() {
|
||||
let t = line.trim_start();
|
||||
if let Some(lang) = t.strip_prefix("```") {
|
||||
end_para(&mut out, &mut para);
|
||||
let mut body: Vec<&str> = Vec::new();
|
||||
for l in lines.by_ref() {
|
||||
if l.trim_start().starts_with("```") {
|
||||
break;
|
||||
}
|
||||
body.push(l);
|
||||
}
|
||||
out.push(Block::Code { lang: lang.trim().into(), text: body.join("\n") });
|
||||
} else if t.is_empty() {
|
||||
end_para(&mut out, &mut para);
|
||||
} else if t.starts_with('#') && t.trim_start_matches('#').starts_with(' ') {
|
||||
end_para(&mut out, &mut para);
|
||||
out.push(Block::Heading(inline(t.trim_start_matches('#').trim())));
|
||||
} else if let Some((start, text)) = list_item(line) {
|
||||
end_para(&mut out, &mut para);
|
||||
match out.last_mut() {
|
||||
Some(Block::List { start: s, items }) if s.is_some() == start.is_some() => items.push(inline(text)),
|
||||
_ => out.push(Block::List { start, items: vec![inline(text)] }),
|
||||
}
|
||||
} else {
|
||||
para.push(t);
|
||||
}
|
||||
}
|
||||
end_para(&mut out, &mut para);
|
||||
out
|
||||
}
|
||||
|
||||
fn job(ui: &Ui, spans: &[Span], size: f32, heading: bool) -> LayoutJob {
|
||||
let p = theme::pal(ui);
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = ui.available_width();
|
||||
for s in spans {
|
||||
let font = match (s.code, s.bold || heading) {
|
||||
(true, _) => FontId::new(size - 1.0, FontFamily::Monospace),
|
||||
(false, true) => theme::semibold(size),
|
||||
_ => FontId::proportional(size),
|
||||
};
|
||||
let mut f = TextFormat { font_id: font, color: p.text, italics: s.italic, ..Default::default() };
|
||||
if s.code {
|
||||
f.background = p.field;
|
||||
f.color = theme::accent();
|
||||
}
|
||||
job.append(&s.text, 0.0, f);
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
/// A run of spans. Links make it a row of separate widgets (so each can be clicked).
|
||||
fn spans(ui: &mut Ui, spans: &[Span], size: f32, heading: bool) {
|
||||
if !spans.iter().any(|s| s.link.is_some()) {
|
||||
ui.add(egui::Label::new(job(ui, spans, size, heading)));
|
||||
return;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for s in spans {
|
||||
match &s.link {
|
||||
Some(url) => {
|
||||
ui.hyperlink_to(RichText::new(&s.text).size(size).color(theme::accent()), url);
|
||||
}
|
||||
None => {
|
||||
ui.add(egui::Label::new(job(ui, std::slice::from_ref(s), size, heading)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn code_block(ui: &mut Ui, lang: &str, text: &str) {
|
||||
let p = theme::pal(ui);
|
||||
egui::Frame::new().fill(p.field).corner_radius(theme::RADIUS_LG).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let label = if lang.is_empty() { "code" } else { lang };
|
||||
ui.label(RichText::new(label).font(theme::semibold(theme::SECONDARY)).color(p.secondary));
|
||||
ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| {
|
||||
if super::kit::lottie_button(ui, "copy", "Copy code", 22.0).clicked() {
|
||||
ui.ctx().copy_text(text.to_owned());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(text).font(FontId::new(theme::SECONDARY + 1.0, FontFamily::Monospace)).color(p.text)).wrap());
|
||||
});
|
||||
}
|
||||
|
||||
/// Parsed blocks of one message, kept while its text does not change.
|
||||
#[derive(Clone)]
|
||||
struct Parsed {
|
||||
len: usize,
|
||||
hash: u64,
|
||||
blocks: Arc<Vec<Block>>,
|
||||
}
|
||||
|
||||
fn hash_of(src: &str) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
src.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Draws `src` as markdown in `ui`. `id` names the message: its parse is cached under it and
|
||||
/// redone only when the text changes (streaming), not every frame.
|
||||
pub fn render(ui: &mut Ui, id: Id, src: &str, size: f32) {
|
||||
let p = theme::pal(ui);
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
let hash = hash_of(src);
|
||||
let parsed = match ui.data(|d| d.get_temp::<Parsed>(id)).filter(|c| c.len == src.len() && c.hash == hash) {
|
||||
Some(c) => c.blocks,
|
||||
None => {
|
||||
let blocks = Arc::new(parse(src));
|
||||
ui.data_mut(|d| d.insert_temp(id, Parsed { len: src.len(), hash, blocks: blocks.clone() }));
|
||||
blocks
|
||||
}
|
||||
};
|
||||
for block in parsed.iter() {
|
||||
match block {
|
||||
Block::Para(s) => spans(ui, s, size, false),
|
||||
Block::Heading(s) => spans(ui, s, size + 1.0, true),
|
||||
Block::Code { lang, text } => code_block(ui, lang, text),
|
||||
Block::List { start, items } => {
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
for (n, item) in items.iter().enumerate() {
|
||||
ui.horizontal_top(|ui| {
|
||||
let mark = start.map_or_else(|| "\u{2022}".to_owned(), |s| format!("{}.", s + n));
|
||||
ui.add_sized([16.0, 0.0], egui::Label::new(RichText::new(mark).size(size).color(p.secondary)));
|
||||
ui.vertical(|ui| spans(ui, item, size, false));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inline_styles() {
|
||||
let s = inline("a **b** *c* `d` [e](http://x)");
|
||||
let kinds: Vec<_> = s.iter().map(|s| (s.text.as_str(), s.bold, s.italic, s.code, s.link.is_some())).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
("a ", false, false, false, false),
|
||||
("b", true, false, false, false),
|
||||
(" ", false, false, false, false),
|
||||
("c", false, true, false, false),
|
||||
(" ", false, false, false, false),
|
||||
("d", false, false, true, false),
|
||||
(" ", false, false, false, false),
|
||||
("e", false, false, false, true),
|
||||
]
|
||||
);
|
||||
assert_eq!(s[7].link.as_deref(), Some("http://x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_markers_stay_literal() {
|
||||
assert_eq!(inline("2 * 3 and `half"), vec![plain("2 * 3 and `half")]);
|
||||
assert_eq!(inline("see [docs]("), vec]);
|
||||
assert_eq!(inline("**bol"), vec![plain("**bol")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_unmatched_brackets_parse_in_linear_time() {
|
||||
let t = std::time::Instant::now();
|
||||
let s = inline(&"[x ".repeat(60_000));
|
||||
assert_eq!(s.iter().map(|s| s.text.len()).sum::<usize>(), 180_000);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed());
|
||||
// The first `[` pairs with the first `](...)` after it.
|
||||
assert_eq!(inline("[a [b](u)").iter().filter(|s| s.link.is_some()).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_paragraphs() {
|
||||
let b = parse("Intro line\ncontinues\n\n- one\n- two `x`\n\n1. a\n2. b");
|
||||
assert_eq!(b.len(), 3);
|
||||
assert_eq!(b[0], Block::Para(inline("Intro line continues")));
|
||||
assert!(matches!(&b[1], Block::List { start: None, items } if items.len() == 2));
|
||||
assert!(matches!(&b[2], Block::List { start: Some(1), items } if items.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_fence_keeps_text_and_lang_and_survives_streaming() {
|
||||
let b = parse("Before\n```html\n<div>\n hi\n</div>\n```\nAfter");
|
||||
assert_eq!(b[1], Block::Code { lang: "html".into(), text: "<div>\n hi\n</div>".into() });
|
||||
assert_eq!(b.len(), 3);
|
||||
// Unterminated fence: the rest is code.
|
||||
let b = parse("```\nlet x = 1;");
|
||||
assert_eq!(b, vec![Block::Code { lang: String::new(), text: "let x = 1;".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_and_render_smoke() {
|
||||
assert!(matches!(parse("## Title")[0], Block::Heading(_)));
|
||||
let ctx = egui::Context::default();
|
||||
crate::ui::setup_style(&ctx);
|
||||
let src = "# H\ntext **b** [l](http://x)\n- a\n1. b\n```rs\nfn main() {}\n```";
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| render(ui, Id::new("md"), src, 13.0));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
@@ -623,3 +623,61 @@ pub fn play_button(ui: &mut Ui, tip: &str) -> Response {
|
||||
.add(egui::Shape::closed_line(pts, Stroke::new(1.6, tint)));
|
||||
})
|
||||
}
|
||||
|
||||
/// Height of [`segmented`].
|
||||
pub const SEGMENTED_H: f32 = 28.0;
|
||||
|
||||
/// A pill-shaped segmented control. Each item is `(label, disabled tooltip)`; an item with a
|
||||
/// tooltip is disabled and shows it on hover. Returns the index the user picked this frame.
|
||||
pub fn segmented(ui: &mut Ui, items: &[(&str, Option<&str>)], selected: usize) -> Option<usize> {
|
||||
let p = theme::pal(ui);
|
||||
let font = theme::semibold(theme::SECONDARY + 1.0);
|
||||
let pad = 12.0;
|
||||
let widths: Vec<f32> = items
|
||||
.iter()
|
||||
.map(|(l, _)| galley_width(ui, l, font.clone()) + 2.0 * pad)
|
||||
.collect();
|
||||
let inset = 2.0;
|
||||
let total = widths.iter().sum::<f32>() + 2.0 * inset;
|
||||
let (outer, _) = ui.allocate_exact_size(vec2(total, SEGMENTED_H), Sense::hover());
|
||||
ui.painter()
|
||||
.rect_filled(outer, CornerRadius::same(PILL), p.field);
|
||||
let mut x = outer.left() + inset;
|
||||
let mut picked = None;
|
||||
for (i, ((label, off), w)) in items.iter().zip(&widths).enumerate() {
|
||||
let rect = Rect::from_min_size(
|
||||
egui::pos2(x, outer.top() + inset),
|
||||
vec2(*w, SEGMENTED_H - 2.0 * inset),
|
||||
);
|
||||
x += w;
|
||||
let sense = if off.is_some() { Sense::hover() } else { Sense::click() };
|
||||
let resp = ui.interact(rect, ui.id().with(("segmented", i)), sense);
|
||||
let on = i == selected;
|
||||
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("seg"), &resp);
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(PILL), theme::accent());
|
||||
} else if off.is_none() {
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
CornerRadius::same(PILL),
|
||||
p.hover.gamma_multiply(hover),
|
||||
);
|
||||
}
|
||||
let ink = if on {
|
||||
on_accent(p)
|
||||
} else if off.is_some() {
|
||||
p.tertiary
|
||||
} else {
|
||||
p.secondary
|
||||
};
|
||||
ui.painter()
|
||||
.text(rect.center(), Align2::CENTER_CENTER, label, font.clone(), ink);
|
||||
if let Some(tip) = off {
|
||||
resp.on_hover_text(*tip);
|
||||
} else if resp.clicked() && !on {
|
||||
picked = Some(i);
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod bind;
|
||||
pub mod chat;
|
||||
pub mod chat_md;
|
||||
pub mod chat_msgs;
|
||||
pub mod confirm;
|
||||
pub mod design;
|
||||
pub mod home;
|
||||
@@ -131,6 +134,8 @@ pub struct UiState {
|
||||
pub review_diff: Option<panels::ReviewDiff>,
|
||||
/// Run (id) waiting for the "Revert agent run?" confirm.
|
||||
pub pending_run_revert: Option<String>,
|
||||
/// Run (id) waiting for the "Restore to here" confirm: it and every later run get reverted.
|
||||
pub pending_restore: Option<String>,
|
||||
/// Where an agent connects (MCP URL natively, the relay URL in the browser); empty until known.
|
||||
pub agent_endpoint: String,
|
||||
/// An extra sidebar view: history, comments or the agent log. None shows layers, library or design.
|
||||
@@ -142,6 +147,9 @@ pub struct UiState {
|
||||
pub toast: Option<(String, bool)>,
|
||||
/// Selected codegen target in the inspector's Code & AI Handoff section.
|
||||
pub code_target: String,
|
||||
/// Chat mode: the chat column replaces the layers and inspector.
|
||||
pub chat_mode: bool,
|
||||
pub chat: chat::ChatState,
|
||||
}
|
||||
|
||||
/// Where a dragged layer lands relative to a row.
|
||||
@@ -248,6 +256,9 @@ impl UiState {
|
||||
pub fn sync_runs(&mut self, t: &mut crate::runs::Tracker) -> bool {
|
||||
self.run_open = t.open_run().cloned();
|
||||
let finished = t.take_finished();
|
||||
if let Some(run) = &finished {
|
||||
self.chat.note_run(run);
|
||||
}
|
||||
if finished.is_some() {
|
||||
self.runs_key = None;
|
||||
}
|
||||
@@ -376,6 +387,8 @@ pub(crate) const PANEL_GAP: f32 = 12.0;
|
||||
|
||||
/// Canvas width under which the top bar drops the zoom pill (it is also on the keyboard).
|
||||
const TIGHT_BAR_W: f32 = 520.0;
|
||||
/// Room the Design | Chat switch takes in the top bar.
|
||||
const CHAT_SWITCH_W: f32 = 140.0;
|
||||
|
||||
/// Canvas width under which the egui menu bar folds into a single "Menu" button.
|
||||
const MENU_BAR_W: f32 = 1100.0;
|
||||
@@ -600,7 +613,27 @@ fn round_corners(painter: &egui::Painter, rect: egui::Rect, r: f32, colour: Colo
|
||||
}
|
||||
|
||||
/// Lay out the chrome and return the remaining canvas rectangle (logical px).
|
||||
impl UiState {
|
||||
/// Switch Design | Chat and remember it for the open project. Chat needs the desktop app
|
||||
/// and an open project.
|
||||
pub fn set_chat_mode(&mut self, on: bool) {
|
||||
let on = on && !cfg!(target_arch = "wasm32") && self.project_dir.is_some();
|
||||
self.chat_mode = on;
|
||||
if let Some(dir) = &self.project_dir {
|
||||
chat::remember_mode(dir, on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
if st.chat.project != st.project_dir {
|
||||
st.chat_mode = st.chat.switch_project(st.project_dir.clone());
|
||||
}
|
||||
if st.chat.mcp_url != st.agent_endpoint {
|
||||
st.chat.mcp_url.clone_from(&st.agent_endpoint);
|
||||
}
|
||||
st.chat.project_name.clone_from(&st.project_name);
|
||||
st.chat.pump(ui.ctx());
|
||||
if st.custom_titlebar {
|
||||
let p = theme::pal(ui);
|
||||
egui::Panel::top("titlebar")
|
||||
@@ -636,6 +669,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
}
|
||||
}
|
||||
UiEvent::ToggleUi => st.hide_ui = !st.hide_ui,
|
||||
UiEvent::ToggleChat => st.set_chat_mode(!st.chat_mode),
|
||||
UiEvent::ToggleHelp => st.show_help = !st.show_help,
|
||||
UiEvent::ToggleGrid => st.show_grid_off = !st.show_grid_off,
|
||||
UiEvent::OpenVariables => st.variables.open = !st.variables.open,
|
||||
@@ -677,58 +711,75 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
// Narrow windows keep the canvas usable: the side panels never take more than 30% each.
|
||||
let window_w = ui.max_rect().width();
|
||||
let side_max = |min: f32| (window_w * SIDE_MAX_SHARE).clamp(min, 430.0);
|
||||
egui::Panel::left("layers")
|
||||
.resizable(true)
|
||||
.default_size(252.0 + 2.0 * gap)
|
||||
.size_range(190.0..=side_max(190.0))
|
||||
.frame(if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
egui::Frame::NONE
|
||||
.fill(sidebar_fill)
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
let mut body = |ui: &mut Ui| {
|
||||
if st.chat_mode {
|
||||
chat::panel(
|
||||
ui,
|
||||
ed,
|
||||
st,
|
||||
if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
egui::Frame::NONE
|
||||
.fill(sidebar_fill)
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
},
|
||||
floating,
|
||||
sidebar_fill,
|
||||
);
|
||||
} else {
|
||||
egui::Panel::left("layers")
|
||||
.resizable(true)
|
||||
.default_size(252.0 + 2.0 * gap)
|
||||
.size_range(190.0..=side_max(190.0))
|
||||
.frame(if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
egui::Frame::NONE
|
||||
.fill(sidebar_fill)
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
let mut body = |ui: &mut Ui| {
|
||||
if floating {
|
||||
egui::Panel::bottom("side-footer")
|
||||
.exact_size(48.0)
|
||||
.frame(egui::Frame::NONE)
|
||||
.show(ui, |ui| account_footer(ui, st));
|
||||
ui.horizontal(|ui| {
|
||||
kit::badge(ui, Icon::Pen, theme::accent(), 28.0);
|
||||
ui.label(RichText::new("PaperCraft").font(theme::semibold(16.0)));
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
layers(ui, ed, st)
|
||||
};
|
||||
if floating {
|
||||
egui::Panel::bottom("side-footer")
|
||||
.exact_size(48.0)
|
||||
.frame(egui::Frame::NONE)
|
||||
.show(ui, |ui| account_footer(ui, st));
|
||||
ui.horizontal(|ui| {
|
||||
kit::badge(ui, Icon::Pen, theme::accent(), 28.0);
|
||||
ui.label(RichText::new("PaperCraft").font(theme::semibold(16.0)));
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
floating_panel(ui, sidebar_fill, egui::Margin::symmetric(14, 12), body);
|
||||
} else {
|
||||
body(ui);
|
||||
}
|
||||
layers(ui, ed, st)
|
||||
};
|
||||
if floating {
|
||||
floating_panel(ui, sidebar_fill, egui::Margin::symmetric(14, 12), body);
|
||||
});
|
||||
egui::Panel::right("design")
|
||||
.resizable(true)
|
||||
.default_size(272.0 + 2.0 * gap)
|
||||
.size_range(230.0..=side_max(230.0))
|
||||
.frame(if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
body(ui);
|
||||
}
|
||||
});
|
||||
egui::Panel::right("design")
|
||||
.resizable(true)
|
||||
.default_size(272.0 + 2.0 * gap)
|
||||
.size_range(230.0..=side_max(230.0))
|
||||
.frame(if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
egui::Frame::NONE
|
||||
.fill(p.inspector)
|
||||
.inner_margin(egui::Margin::symmetric(14, 10))
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
if floating {
|
||||
floating_panel(ui, p.inspector, egui::Margin::symmetric(14, 12), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(p.inspector)
|
||||
.inner_margin(egui::Margin::symmetric(14, 10))
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
if floating {
|
||||
floating_panel(ui, p.inspector, egui::Margin::symmetric(14, 12), |ui| {
|
||||
inspector::panel(ui, ed, st)
|
||||
});
|
||||
} else {
|
||||
inspector::panel(ui, ed, st)
|
||||
});
|
||||
} else {
|
||||
inspector::panel(ui, ed, st)
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
egui::Panel::top("toolbar")
|
||||
.exact_size(if floating {
|
||||
theme::TOOLBAR_H + gap * 2.0
|
||||
@@ -770,6 +821,12 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
);
|
||||
}
|
||||
toolbar(ui.ctx(), ed, canvas);
|
||||
if st.chat_mode && st.chat.inspector {
|
||||
chat::inspector_overlay(ui.ctx(), ed, st, canvas);
|
||||
}
|
||||
if st.chat_mode {
|
||||
chat::canvas_pill(ui.ctx(), st, canvas);
|
||||
}
|
||||
agent_glow(ui.ctx(), st, canvas);
|
||||
status(ui.ctx(), ed, canvas);
|
||||
review_overlay(ui.ctx(), ed, st, canvas);
|
||||
@@ -853,7 +910,11 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
if home.on_hover_text("All projects").clicked() {
|
||||
ed.requests.push(crate::editor::AppRequest::Home);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(8.0);
|
||||
if let Some(on) = chat::mode_switch(ui, st.chat_mode) {
|
||||
st.set_chat_mode(on);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let title = if st.project_name.is_empty() {
|
||||
ed.doc.name.clone()
|
||||
} else {
|
||||
@@ -869,7 +930,8 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
300.0
|
||||
} else {
|
||||
420.0
|
||||
})
|
||||
}
|
||||
- CHAT_SWITCH_W)
|
||||
.max(40.0),
|
||||
);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
@@ -942,6 +1004,14 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
if kit::play_button(ui, "Present").clicked() {
|
||||
start_present(ed, st);
|
||||
}
|
||||
if st.chat_mode
|
||||
&& !compact
|
||||
&& theme::icon_button(ui, theme::Icon::Sidebar, 26.0, st.chat.inspector)
|
||||
.on_hover_text("Inspector")
|
||||
.clicked()
|
||||
{
|
||||
st.chat.inspector = !st.chat.inspector;
|
||||
}
|
||||
if !compact {
|
||||
if kit::history_button(ui, true, ed.history.redo_len() > 0).clicked() {
|
||||
ed.run(Action::Redo);
|
||||
|
||||
@@ -642,8 +642,20 @@ fn runs_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
/// Marks a run Accepted (the run card's Accept in Chat mode).
|
||||
pub(super) fn accept_run(ed: &mut Editor, st: &mut UiState, id: &str) {
|
||||
let (Some(dir), Some(mut run)) = (st.project_dir.clone(), st.runs.iter().find(|r| r.id == id).cloned()) else {
|
||||
return;
|
||||
};
|
||||
run.state = RunState::Accepted;
|
||||
if let Err(e) = crate::runs::save(&dir, &run) {
|
||||
ed.status = format!("Could not save the run: {e}");
|
||||
}
|
||||
st.runs_key = None;
|
||||
}
|
||||
|
||||
/// Confirm, then put every page of the run back to how it was before it.
|
||||
fn revert_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, dir: &std::path::Path) {
|
||||
pub(super) fn revert_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, dir: &std::path::Path) {
|
||||
let Some(id) = st.pending_run_revert.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -671,6 +683,37 @@ fn revert_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, dir: &s
|
||||
st.runs_key = None;
|
||||
}
|
||||
|
||||
/// One confirm for "Restore to here": reverts the run and every later run, newest first.
|
||||
pub(super) fn restore_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, dir: &std::path::Path) {
|
||||
let Some(id) = st.pending_restore.clone() else {
|
||||
return;
|
||||
};
|
||||
let runs = crate::runs::runs_from(&st.runs, &id);
|
||||
let body = format!(
|
||||
"This reverts {}, newest first, on every page they touched. Edits you made to those pages since are lost.",
|
||||
count(runs.len(), "agent run")
|
||||
);
|
||||
let Some(yes) = super::confirm::dialog(ctx, "run-restore", "Restore to here?", &body, "Restore") else {
|
||||
return;
|
||||
};
|
||||
st.pending_restore = None;
|
||||
if !yes || runs.is_empty() {
|
||||
return;
|
||||
}
|
||||
if st.run_open.is_some() {
|
||||
ed.status = "Wait for the agent run in progress to finish".into();
|
||||
return;
|
||||
}
|
||||
if st.review_run.as_ref().is_some_and(|r| runs.iter().any(|x| &x.id == r)) {
|
||||
st.review_run = None;
|
||||
}
|
||||
ed.status = match crate::runs::revert_all(dir, ed, runs) {
|
||||
Ok(n) => format!("Reverted {}", count(n, "agent run")),
|
||||
Err(e) => format!("Could not restore: {e}"),
|
||||
};
|
||||
st.runs_key = None;
|
||||
}
|
||||
|
||||
fn activity_section(ui: &mut Ui, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
kit::section_header(ui, "Recent activity", None);
|
||||
@@ -879,7 +922,7 @@ fn diff_row(
|
||||
}
|
||||
|
||||
/// The run review: changes by frame, per-row revert, Revert run and Accept all.
|
||||
fn review(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
pub(super) fn review(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
let (Some(dir), Some(id)) = (st.project_dir.clone(), st.review_run.clone()) else {
|
||||
st.review_run = None;
|
||||
|
||||
@@ -1,21 +1,252 @@
|
||||
//! Settings dialog: appearance, editor, updates and a way to the keyboard shortcuts sheet.
|
||||
//! Settings dialog: appearance, canvas, chat agents and keys, updates.
|
||||
|
||||
use super::{UiState, kit, motion};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::confirm;
|
||||
use crate::chat::BackendKind;
|
||||
use crate::chat::endpoint::{self, Endpoint};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::chat::endpoint::ApiFormat;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::chat::{claude_code, codex};
|
||||
use crate::theme::{self, Icon};
|
||||
use egui::{RichText, Ui};
|
||||
use std::path::PathBuf;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
#[default]
|
||||
Appearance,
|
||||
Canvas,
|
||||
Chat,
|
||||
Updates,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub const API_MODELS: [(&str, &str); 3] = [
|
||||
("claude-opus-5-5", "Opus 5.5"),
|
||||
("claude-sonnet-5-5", "Sonnet 5.5"),
|
||||
("claude-haiku-5-5", "Haiku 5.5"),
|
||||
];
|
||||
pub const API_EFFORTS: [(&str, &str); 3] = [("low", "Low"), ("medium", "Medium"), ("high", "High")];
|
||||
const DEFAULT_MODEL: &str = "claude-opus-5-5";
|
||||
const DEFAULT_EFFORT: &str = "medium";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const BACKENDS: [(BackendKind, &str); 3] = [
|
||||
(BackendKind::ClaudeCode, "Claude Code"),
|
||||
(BackendKind::Codex, "Codex"),
|
||||
(BackendKind::Anthropic, "API"),
|
||||
];
|
||||
|
||||
/// Chat defaults and agent paths, kept in `chat_settings.json` in the config folder.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ChatPrefs {
|
||||
/// Override for the `claude` binary; None means the first one on `PATH`.
|
||||
pub claude_path: Option<String>,
|
||||
/// Override for the `codex` binary; None means the first one on `PATH`.
|
||||
pub codex_path: Option<String>,
|
||||
pub default_backend: BackendKind,
|
||||
/// API endpoints, at least one. Keys are in the keychain, never here.
|
||||
pub endpoints: Vec<Endpoint>,
|
||||
/// Id of the endpoint new chats start on.
|
||||
pub default_endpoint: String,
|
||||
/// One of [`API_EFFORTS`]; sent to `api.anthropic.com` only.
|
||||
pub api_effort: String,
|
||||
/// Old settings files: read once into the default endpoint's model, never written.
|
||||
#[serde(skip_serializing)]
|
||||
pub api_model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ChatPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
claude_path: None,
|
||||
codex_path: None,
|
||||
default_backend: BackendKind::ClaudeCode,
|
||||
endpoints: vec![Endpoint::default()],
|
||||
default_endpoint: endpoint::DEFAULT_ENDPOINT_ID.into(),
|
||||
api_effort: DEFAULT_EFFORT.into(),
|
||||
api_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatPrefs {
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Unreadable input means defaults; an unknown effort falls back to the default. A file from
|
||||
/// before endpoints existed (`api_model` and no `endpoints`) moves its model into the default
|
||||
/// `Anthropic` endpoint.
|
||||
pub fn from_json(s: &str) -> Self {
|
||||
let has_endpoints = serde_json::from_str::<serde_json::Value>(s).is_ok_and(|v| v["endpoints"].is_array());
|
||||
let mut prefs: Self = serde_json::from_str(s).unwrap_or_default();
|
||||
let legacy_model = prefs.api_model.take();
|
||||
if prefs.endpoints.is_empty() {
|
||||
prefs.endpoints = vec![Endpoint::default()];
|
||||
}
|
||||
if !has_endpoints {
|
||||
let model = legacy_model.filter(|m| API_MODELS.iter().any(|(id, _)| id == m));
|
||||
prefs.endpoints[0].model = model.unwrap_or_else(|| DEFAULT_MODEL.into());
|
||||
}
|
||||
if !prefs.endpoints.iter().any(|e| e.id == prefs.default_endpoint) {
|
||||
prefs.default_endpoint = prefs.endpoints[0].id.clone();
|
||||
}
|
||||
if !API_EFFORTS.iter().any(|(id, _)| *id == prefs.api_effort) {
|
||||
prefs.api_effort = DEFAULT_EFFORT.into();
|
||||
}
|
||||
prefs
|
||||
}
|
||||
|
||||
/// The endpoint with this id; None or an unknown id means the default one.
|
||||
pub fn endpoint(&self, id: Option<&str>) -> &Endpoint {
|
||||
id.and_then(|id| self.endpoints.iter().find(|e| e.id == id)).unwrap_or_else(|| self.default_endpoint())
|
||||
}
|
||||
|
||||
pub fn default_endpoint(&self) -> &Endpoint {
|
||||
self.endpoints.iter().find(|e| e.id == self.default_endpoint).unwrap_or(&self.endpoints[0])
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return dirs::config_dir().map(|d| d.join("papercraft").join("chat_settings.json"));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
Self::path()
|
||||
.and_then(|p| crate::vfs::read_to_string(p).ok())
|
||||
.map_or_else(Self::default, |s| Self::from_json(&s))
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let Some(p) = Self::path() else { return };
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = crate::vfs::create_dir_all(dir);
|
||||
}
|
||||
let _ = crate::vfs::write(p, self.to_json());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsUi {
|
||||
pub open: bool,
|
||||
page: Page,
|
||||
/// Chat defaults and agent paths. The chat UI reads these, e.g. `st.settings.chat.default_backend`.
|
||||
pub chat: ChatPrefs,
|
||||
/// Paths, key or Re-scan changed on the Chat page: the chat column detects the agents again.
|
||||
pub chat_changed: bool,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
chat_page: ChatPage,
|
||||
}
|
||||
|
||||
impl SettingsUi {
|
||||
/// Open the dialog on its Chat page.
|
||||
pub fn open_chat(&mut self) {
|
||||
self.open = true;
|
||||
self.page = Page::Chat;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SettingsUi {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
page: Page::default(),
|
||||
chat: ChatPrefs::load(),
|
||||
chat_changed: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
chat_page: ChatPage::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat page state that lives only while the dialog is open.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct ChatPage {
|
||||
/// Set when the page is first shown after the dialog opened; cleared when the dialog closes.
|
||||
scan: Option<Scan>,
|
||||
/// Typed keys by endpoint id.
|
||||
key_drafts: std::collections::HashMap<String, String>,
|
||||
key_error: Option<String>,
|
||||
/// The endpoint whose key waits for the remove confirmation.
|
||||
key_remove: Option<String>,
|
||||
}
|
||||
|
||||
/// What the page shows about the CLIs and the stored key, read once (not every frame).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Scan {
|
||||
claude: Option<Found>,
|
||||
codex: Option<Found>,
|
||||
/// Last four characters of each endpoint's key (by id); the keys themselves are never kept here.
|
||||
key_tails: std::collections::HashMap<String, String>,
|
||||
env_key: bool,
|
||||
}
|
||||
|
||||
/// Binary path and version.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type Found = (PathBuf, String);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Scan {
|
||||
fn read(prefs: &ChatPrefs) -> Scan {
|
||||
Scan {
|
||||
claude: claude_code::detect(prefs.claude_path.as_deref().map(Path::new)),
|
||||
codex: codex::detect(prefs.codex_path.as_deref().map(Path::new)),
|
||||
key_tails: prefs.endpoints.iter().filter_map(|e| Some((e.id.clone(), key_tail(&endpoint::get_key(e)?)))).collect(),
|
||||
env_key: std::env::var(KEY_ENV).is_ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const KEY_ENV: &str = "ANTHROPIC_API_KEY";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const KEY_TAIL_CHARS: usize = 4;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const KEY_MASK: &str = "•••• ";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const KEY_PLACEHOLDER: &str = "API key (optional for a local server)";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const CLAUDE_KEY_PLACEHOLDER: &str = "sk-ant-…";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const CLAUDE_INSTALL: &str = "npm i -g @anthropic-ai/claude-code";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const CODEX_INSTALL: &str = "npm i -g @openai/codex";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const AGENT_NAME_W: f32 = 150.0;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const AGENT_CHIP_W: f32 = 84.0;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const KEY_BUTTONS_W: f32 = 180.0;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const FORMAT_W: f32 = 130.0;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const FORMATS: [(ApiFormat, &str); 2] = [(ApiFormat::Anthropic, "Anthropic format"), (ApiFormat::OpenAi, "OpenAI format")];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const DEFAULT_GAP: f32 = 12.0;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn key_tail(key: &str) -> String {
|
||||
let chars: Vec<char> = key.chars().collect();
|
||||
chars[chars.len().saturating_sub(KEY_TAIL_CHARS)..].iter().collect()
|
||||
}
|
||||
|
||||
/// Where the platform keeps the API key, for the settings note.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn keychain_name() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS Keychain"
|
||||
} else if cfg!(windows) {
|
||||
"Windows Credential Manager"
|
||||
} else {
|
||||
"Secret Service"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ctx: &egui::Context, st: &mut UiState) {
|
||||
@@ -27,6 +258,11 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
|
||||
motion::ease_out_cubic,
|
||||
);
|
||||
if !st.settings.open && t <= 0.0 {
|
||||
// Closed: the next opening scans again.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
st.settings.chat_page.scan = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let live = st.settings.open;
|
||||
@@ -56,6 +292,7 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
|
||||
for (page, icon, label) in [
|
||||
(Page::Appearance, Icon::Sketch, "Appearance"),
|
||||
(Page::Canvas, Icon::Grid, "Canvas"),
|
||||
(Page::Chat, Icon::Component, "Chat"),
|
||||
(Page::Updates, Icon::Restore, "Updates"),
|
||||
] {
|
||||
if kit::nav_item(ui, icon, label, st.settings.page == page) {
|
||||
@@ -88,6 +325,7 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
|
||||
let (title, sub) = match st.settings.page {
|
||||
Page::Appearance => ("Appearance", "Theme, scale and motion."),
|
||||
Page::Canvas => ("Canvas", "How the canvas looks."),
|
||||
Page::Chat => ("Chat", "Agents and keys for design by conversation."),
|
||||
Page::Updates => ("Updates", "Keep PaperCraft current."),
|
||||
};
|
||||
ui.vertical(|ui| {
|
||||
@@ -165,6 +403,7 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
|
||||
},
|
||||
);
|
||||
}
|
||||
Page::Chat => chat_page(ui, ctx, &mut st.settings),
|
||||
Page::Updates => {
|
||||
kit::setting_row(
|
||||
ui,
|
||||
@@ -185,3 +424,370 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn chat_page(ui: &mut Ui, _ctx: &egui::Context, _settings: &mut SettingsUi) {
|
||||
let p = theme::pal(ui);
|
||||
ui.label(RichText::new("Chat needs the PaperCraft desktop app.").color(p.secondary));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn chat_page(ui: &mut Ui, ctx: &egui::Context, settings: &mut SettingsUi) {
|
||||
let p = theme::pal(ui);
|
||||
let SettingsUi { chat: prefs, chat_page: page, .. } = &mut *settings;
|
||||
let mut key_changed = false;
|
||||
let mut scan = page.scan.take().unwrap_or_else(|| Scan::read(prefs));
|
||||
let mut save = false;
|
||||
let mut rescan = false;
|
||||
|
||||
kit::section_header(ui, "Local agents", None);
|
||||
ui.add_space(4.0);
|
||||
let claude_commit = agent_row(
|
||||
ui,
|
||||
("Claude Code", CLAUDE_INSTALL, "claude"),
|
||||
scan.claude.as_ref(),
|
||||
&mut prefs.claude_path,
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
let codex_commit = agent_row(
|
||||
ui,
|
||||
("Codex", CODEX_INSTALL, "codex"),
|
||||
scan.codex.as_ref(),
|
||||
&mut prefs.codex_path,
|
||||
);
|
||||
if claude_commit || codex_commit {
|
||||
save = true;
|
||||
rescan = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Detected on PATH. Set a path to override.").size(theme::SECONDARY).color(p.secondary));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if kit::button(ui, kit::ButtonKind::Secondary, None, "Re-scan").clicked() {
|
||||
rescan = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
kit::section_header(ui, "API endpoints", None);
|
||||
ui.add_space(4.0);
|
||||
let note = |ui: &mut Ui, text: &str| {
|
||||
ui.label(RichText::new(text).size(theme::SECONDARY).color(p.secondary));
|
||||
};
|
||||
note(ui, "Any server that speaks the Anthropic Messages or the OpenAI Chat Completions format. A key is optional for local servers.");
|
||||
ui.add_space(6.0);
|
||||
let edits = endpoint_cards(ui, prefs, page, &mut scan);
|
||||
save |= edits.save;
|
||||
rescan |= edits.rescan;
|
||||
key_changed |= edits.key_changed;
|
||||
ui.add_space(6.0);
|
||||
if kit::button(ui, kit::ButtonKind::Secondary, Some(Icon::Plus), "Add endpoint").clicked() {
|
||||
prefs.endpoints.push(Endpoint::blank());
|
||||
save = true;
|
||||
}
|
||||
if let Some(id) = page.key_remove.clone() {
|
||||
let answer = confirm::dialog(
|
||||
ctx,
|
||||
"chat-key-remove",
|
||||
"Remove API key?",
|
||||
"The key is deleted from the Keychain. Chats on this endpoint stop until a key is saved again.",
|
||||
"Remove",
|
||||
);
|
||||
match answer {
|
||||
Some(true) => {
|
||||
page.key_remove = None;
|
||||
if let Some(ep) = prefs.endpoints.iter().find(|e| e.id == id) {
|
||||
match endpoint::delete_key(ep) {
|
||||
Ok(()) => {
|
||||
key_changed = true;
|
||||
scan.key_tails.remove(&id);
|
||||
page.key_error = None;
|
||||
}
|
||||
Err(e) => page.key_error = Some(format!("Could not remove the key: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(false) => page.key_remove = None,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Some(e) = &page.key_error {
|
||||
ui.label(RichText::new(e).color(ui.visuals().error_fg_color));
|
||||
}
|
||||
if !scan.key_tails.is_empty() {
|
||||
note(ui, &format!("Keys are stored in the {} and never written to the project or logs.", keychain_name()));
|
||||
}
|
||||
if scan.env_key {
|
||||
note(ui, &format!("{KEY_ENV} from the environment is used for api.anthropic.com when no key is stored."));
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
kit::section_header(ui, "Defaults for new chats", None);
|
||||
ui.add_space(4.0);
|
||||
let w = ((ui.available_width() - 2.0 * DEFAULT_GAP) / 3.0).max(0.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = DEFAULT_GAP;
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(w);
|
||||
note(ui, "Backend");
|
||||
if let Some(b) = kit::select(ui, prefs.default_backend, &BACKENDS, w) {
|
||||
prefs.default_backend = b;
|
||||
save = true;
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(w);
|
||||
note(ui, "Endpoint (API)");
|
||||
let options: Vec<(usize, &str)> = prefs.endpoints.iter().enumerate().map(|(i, e)| (i, e.name.as_str())).collect();
|
||||
let current = prefs.endpoints.iter().position(|e| e.id == prefs.default_endpoint).unwrap_or(0);
|
||||
if let Some(i) = kit::select(ui, current, &options, w) {
|
||||
prefs.default_endpoint = prefs.endpoints[i].id.clone();
|
||||
save = true;
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(w);
|
||||
note(ui, "Effort (api.anthropic.com)");
|
||||
let effort = API_EFFORTS.iter().map(|(id, _)| *id).find(|id| *id == prefs.api_effort).unwrap_or(DEFAULT_EFFORT);
|
||||
if let Some(e) = kit::select(ui, effort, &API_EFFORTS, w) {
|
||||
prefs.api_effort = e.to_owned();
|
||||
save = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if save {
|
||||
prefs.save();
|
||||
}
|
||||
if rescan {
|
||||
scan = Scan::read(prefs);
|
||||
}
|
||||
page.scan = Some(scan);
|
||||
settings.chat_changed |= rescan || key_changed;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct EndpointEdits {
|
||||
save: bool,
|
||||
rescan: bool,
|
||||
key_changed: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn edit(ui: &mut Ui, text: &mut String, hint: &str, width: f32, out: &mut EndpointEdits) {
|
||||
let r = ui.add(egui::TextEdit::singleline(text).margin(theme::INPUT_MARGIN).hint_text(hint).desired_width(width));
|
||||
out.save |= r.changed();
|
||||
out.rescan |= r.lost_focus();
|
||||
}
|
||||
|
||||
/// One card per endpoint: name, default chip, remove; format and base URL; model; key.
|
||||
/// Text edits save as you type and re-scan when the field loses focus.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn endpoint_cards(ui: &mut Ui, prefs: &mut ChatPrefs, page: &mut ChatPage, scan: &mut Scan) -> EndpointEdits {
|
||||
let p = theme::pal(ui);
|
||||
let mut out = EndpointEdits::default();
|
||||
let mut remove = None;
|
||||
let mut make_default = None;
|
||||
let count = prefs.endpoints.len();
|
||||
for (i, ep) in prefs.endpoints.iter_mut().enumerate() {
|
||||
let is_default = ep.id == prefs.default_endpoint;
|
||||
let claude = ep.is_claude_host();
|
||||
egui::Frame::new().fill(p.backdrop).corner_radius(theme::RADIUS_LG).inner_margin(12).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - 130.0).max(0.0);
|
||||
edit(ui, &mut ep.name, "Name", w, &mut out);
|
||||
if kit::chip(ui, if is_default { "Default" } else { "Make default" }, is_default).clicked() {
|
||||
make_default = Some(ep.id.clone());
|
||||
}
|
||||
if count > 1 && theme::icon_button(ui, Icon::Trash, 22.0, false).on_hover_text("Remove endpoint").clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(f) = kit::select(ui, ep.format, &FORMATS, FORMAT_W) {
|
||||
ep.format = f;
|
||||
out.save = true;
|
||||
out.rescan = true;
|
||||
}
|
||||
let w = ui.available_width().max(0.0);
|
||||
edit(ui, &mut ep.base_url, "https://api.anthropic.com or http://127.0.0.1:11434/v1", w, &mut out);
|
||||
});
|
||||
let w = ui.available_width().max(0.0);
|
||||
edit(ui, &mut ep.model, "Model id", w, &mut out);
|
||||
let tail = scan.key_tails.get(&ep.id).cloned();
|
||||
let draft = page.key_drafts.entry(ep.id.clone()).or_default();
|
||||
let hint = match &tail {
|
||||
Some(t) => format!("{KEY_MASK}{t}"),
|
||||
None if claude => CLAUDE_KEY_PLACEHOLDER.to_string(),
|
||||
None => KEY_PLACEHOLDER.to_string(),
|
||||
};
|
||||
let mut save_clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - KEY_BUTTONS_W).max(0.0);
|
||||
ui.add(egui::TextEdit::singleline(draft).password(true).margin(theme::INPUT_MARGIN).hint_text(hint).desired_width(w));
|
||||
let can_save = !draft.trim().is_empty();
|
||||
save_clicked = ui.add_enabled_ui(can_save, |ui| kit::button(ui, kit::ButtonKind::Primary, None, "Save").clicked()).inner;
|
||||
if tail.is_some() && kit::button(ui, kit::ButtonKind::Danger, Some(Icon::Trash), "Remove").clicked() {
|
||||
page.key_remove = Some(ep.id.clone());
|
||||
}
|
||||
});
|
||||
if save_clicked {
|
||||
match endpoint::set_key(ep, draft) {
|
||||
Ok(()) => {
|
||||
out.key_changed = true;
|
||||
scan.key_tails.insert(ep.id.clone(), key_tail(draft.trim()));
|
||||
draft.clear();
|
||||
page.key_error = None;
|
||||
}
|
||||
Err(e) => page.key_error = Some(format!("Could not save the key: {e}")),
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let Some(id) = make_default {
|
||||
prefs.default_endpoint = id;
|
||||
out.save = true;
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
let gone = prefs.endpoints.remove(i);
|
||||
// The key goes too, unless another endpoint uses the same base URL.
|
||||
let shared = prefs.endpoints.iter().any(|e| endpoint::normalize_url(&e.base_url) == endpoint::normalize_url(&gone.base_url));
|
||||
if !shared {
|
||||
let _ = endpoint::delete_key(&gone);
|
||||
}
|
||||
scan.key_tails.remove(&gone.id);
|
||||
page.key_drafts.remove(&gone.id);
|
||||
if prefs.default_endpoint == gone.id {
|
||||
prefs.default_endpoint = prefs.endpoints[0].id.clone();
|
||||
}
|
||||
out.save = true;
|
||||
out.rescan = true;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One CLI: name and version, the path field (empty means PATH), and a found or missing chip.
|
||||
/// Returns true when the path was committed (the field lost focus), so the caller saves and rescans.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn agent_row(ui: &mut Ui, (name, install, bin): (&str, &str, &str), found: Option<&Found>, path: &mut Option<String>) -> bool {
|
||||
let p = theme::pal(ui);
|
||||
let mut committed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(AGENT_NAME_W);
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.label(RichText::new(name).font(theme::semibold(theme::BODY)));
|
||||
if let Some((_, version)) = found {
|
||||
ui.label(RichText::new(format!("v{version}")).size(theme::SECONDARY).color(p.secondary));
|
||||
}
|
||||
});
|
||||
let hint = found.map_or_else(|| bin.to_string(), |(bin, _)| bin.display().to_string());
|
||||
let mut text = path.clone().unwrap_or_default();
|
||||
let field = ui.add(
|
||||
egui::TextEdit::singleline(&mut text)
|
||||
.hint_text(hint)
|
||||
.desired_width((ui.available_width() - AGENT_CHIP_W).max(0.0)),
|
||||
);
|
||||
if field.changed() {
|
||||
*path = (!text.is_empty()).then_some(text);
|
||||
}
|
||||
if field.lost_focus() {
|
||||
*path = path.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
committed = true;
|
||||
}
|
||||
if found.is_some() {
|
||||
kit::chip_ink(ui, "Found", Some(p.success));
|
||||
} else {
|
||||
kit::chip_ink(ui, "Not found", None);
|
||||
}
|
||||
});
|
||||
if found.is_none() {
|
||||
ui.label(RichText::new(format!("Not found. Install: {install}")).size(theme::SECONDARY).color(p.secondary));
|
||||
}
|
||||
committed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::endpoint::ApiFormat;
|
||||
|
||||
#[test]
|
||||
fn chat_prefs_round_trip_with_new_fields() {
|
||||
let prefs = ChatPrefs {
|
||||
claude_path: Some("/opt/homebrew/bin/claude".into()),
|
||||
codex_path: Some("/usr/local/bin/codex".into()),
|
||||
default_backend: BackendKind::Anthropic,
|
||||
endpoints: vec![
|
||||
Endpoint { model: "claude-haiku-5-5".into(), ..Default::default() },
|
||||
Endpoint { id: "ep-1".into(), name: "Local".into(), format: ApiFormat::OpenAi, base_url: "http://127.0.0.1:11434/v1".into(), model: "llama".into() },
|
||||
],
|
||||
default_endpoint: "ep-1".into(),
|
||||
api_effort: "high".into(),
|
||||
api_model: None,
|
||||
};
|
||||
assert_eq!(ChatPrefs::from_json(&prefs.to_json()), prefs);
|
||||
assert_eq!(ChatPrefs::from_json(&ChatPrefs::default().to_json()), ChatPrefs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_model_and_effort_move_into_the_default_endpoint() {
|
||||
let prefs = ChatPrefs::from_json(r#"{"default_backend":"anthropic","api_model":"claude-haiku-5-5","api_effort":"high"}"#);
|
||||
assert_eq!(prefs.endpoints, vec![Endpoint { model: "claude-haiku-5-5".into(), ..Default::default() }]);
|
||||
assert_eq!((prefs.default_endpoint.as_str(), prefs.api_effort.as_str()), ("anthropic", "high"));
|
||||
assert_eq!(prefs.api_model, None);
|
||||
assert!(!prefs.to_json().contains("api_model"), "migrated once, not written back");
|
||||
// Saved and loaded again it is stable.
|
||||
assert_eq!(ChatPrefs::from_json(&prefs.to_json()), prefs);
|
||||
// With endpoints present the old model is ignored.
|
||||
let both = ChatPrefs::from_json(r#"{"api_model":"claude-haiku-5-5","endpoints":[{"id":"x","name":"X","base_url":"http://h","model":"m"}],"default_endpoint":"gone"}"#);
|
||||
assert_eq!((both.endpoints[0].model.as_str(), both.default_endpoint.as_str()), ("m", "x"));
|
||||
assert_eq!(both.endpoint(Some("nope")).id, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_prefs_fall_back_to_defaults() {
|
||||
let prefs = ChatPrefs::from_json(r#"{"api_model":"gpt-x","api_effort":"max"}"#);
|
||||
assert_eq!(prefs.endpoints[0].model, DEFAULT_MODEL);
|
||||
assert_eq!(prefs.api_effort, DEFAULT_EFFORT);
|
||||
assert_eq!(ChatPrefs::from_json(r#"{"endpoints":[]}"#).endpoints, vec![Endpoint::default()]);
|
||||
assert_eq!(prefs.default_backend, BackendKind::ClaudeCode);
|
||||
assert_eq!(ChatPrefs::from_json("not json"), ChatPrefs::default());
|
||||
}
|
||||
|
||||
/// Seeds the scan so the page renders with no CLI on the machine and no key, without
|
||||
/// spawning processes or reading the Keychain.
|
||||
#[test]
|
||||
fn chat_page_draws_with_no_clis_and_no_key() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::ui::setup_style(&ctx);
|
||||
let mut st = UiState::default();
|
||||
st.settings = SettingsUi {
|
||||
open: true,
|
||||
page: Page::Chat,
|
||||
chat: ChatPrefs::default(),
|
||||
chat_changed: false,
|
||||
chat_page: ChatPage {
|
||||
scan: Some(Scan { claude: None, codex: None, key_tails: Default::default(), env_key: false }),
|
||||
..ChatPage::default()
|
||||
},
|
||||
};
|
||||
let mut ed = crate::editor::Editor::default();
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::ui::draw(ui, &mut ed, &mut st);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert!(st.settings.chat_page.scan.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,3 +176,68 @@ fn refusal_compares_canonical_paths() {
|
||||
assert!(refuse_if_open(&items, Some(via_dots.to_str().unwrap())).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The same tool sequence on a headless host and on one set up like the app window gives the
|
||||
/// same node rects, and the headless one saves no camera (the app fits the page when opened).
|
||||
#[test]
|
||||
fn offline_matches_app_placement_and_saves_no_camera() {
|
||||
use papercraft::mcp::exec::{self, SessionHost};
|
||||
use papercraft::geom::V2;
|
||||
|
||||
fn run(app_like: bool) -> (Vec<String>, std::path::PathBuf, SessionHost) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"pc-cli-parity-{app_like}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut h = SessionHost::new(Store::open(&root).unwrap());
|
||||
if app_like {
|
||||
h.ed.headless = false;
|
||||
h.ed.viewport = V2 { x: 1123.0, y: 860.0 };
|
||||
}
|
||||
let mut call = |n: &str, a: Value| json_of(exec::call(&mut h, n, &a).unwrap());
|
||||
let f = call("create_file", json!({"name": "Demo"}));
|
||||
call("open_file", json!({"fileId": f["fileId"]}));
|
||||
let ab = call(
|
||||
"create_artboard",
|
||||
json!({"name": "Home", "styles": {"width": "390px", "height": "600px"}}),
|
||||
);
|
||||
call(
|
||||
"write_html",
|
||||
json!({"targetNodeId": ab["id"], "mode": "insert-children",
|
||||
"html": "<div style=\"font-size:24px\">Hello</div>"}),
|
||||
);
|
||||
call("create_shape", json!({"type": "rectangle", "styles": {"width": "50px", "height": "50px"}}));
|
||||
call("create_artboard", json!({"name": "Two", "styles": {"width": "300px", "height": "300px"}}));
|
||||
let p = call("create_page", json!({"name": "Second"}));
|
||||
call(
|
||||
"create_artboard",
|
||||
json!({"name": "Other", "pageId": p["pageId"], "styles": {"width": "200px", "height": "200px"}}),
|
||||
);
|
||||
exec::call(&mut h, "finish_working_on_nodes", &json!({})).unwrap();
|
||||
let rects = (0..h.ed.pages.len())
|
||||
.flat_map(|i| {
|
||||
let d = h.ed.page_data(i).doc;
|
||||
d.nodes.values().map(|n| format!("{i} {} {:?}", n.name, n.rect)).collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let dir = h.session.project.as_ref().unwrap().dir.clone();
|
||||
(rects, dir, h)
|
||||
}
|
||||
|
||||
let (mut headless, dir_a, _a) = run(false);
|
||||
let (mut app, dir_b, _b) = run(true);
|
||||
headless.sort();
|
||||
app.sort();
|
||||
assert_eq!(headless, app);
|
||||
let cams = |d: &std::path::Path| {
|
||||
std::fs::read_dir(d.join("pages"))
|
||||
.unwrap()
|
||||
.filter(|e| std::fs::read_to_string(e.as_ref().unwrap().path()).unwrap().contains("\"camera\""))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(cams(&dir_a), 0, "headless camera must not be saved");
|
||||
assert_eq!(cams(&dir_b), 1, "app saves the active page camera");
|
||||
let _ = std::fs::remove_dir_all(dir_a.parent().unwrap());
|
||||
let _ = std::fs::remove_dir_all(dir_b.parent().unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_d","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"stop_reason":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_D","name":"delete_pages","input":{}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"pageIds\":[\"p1\"]}"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_03","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"ENC"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"A long answer that gets cut"}}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":64}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_02","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"stop_reason":null,"usage":{"input_tokens":50,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I can"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"type":"refusal","category":"cyber"}},"usage":{"output_tokens":4}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_r1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"stop_reason":null,"usage":{"input_tokens":100,"cache_creation_input_tokens":20,"cache_read_input_tokens":0,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Plan the board."}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG1"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Creating it."}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_A","name":"create_artboard","input":{}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"name\":\"Home\",\"styles\":{\"width\":\"1440px\","}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"height\":\"900px\"}}"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":2}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":3,"content_block":{"type":"tool_use","id":"toolu_B","name":"get_basic_info","input":{}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":3,"delta":{"type":"input_json_delta","partial_json":""}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":3}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":50}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_r2","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"stop_reason":null,"usage":{"input_tokens":40,"cache_creation_input_tokens":0,"cache_read_input_tokens":120,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Done: "}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"board ready."}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":12}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":120,"cache_creation_input_tokens":30,"cache_read_input_tokens":900,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}
|
||||
|
||||
event: ping
|
||||
data: {"type":"ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Need an artboard"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" then a frame."}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBCkYIBxgC"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Creating "}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"the home screen."}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_A","name":"create_artboard","input":{}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"name\": \"Ho"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"me\", \"width\""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":": 1440}"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":2}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":3,"content_block":{"type":"tool_use","id":"toolu_B","name":"get_basic_info","input":{}}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":3}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":87}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{"type":"system","subtype":"init","cwd":"/home/user/scratch","session_id":"1c1e03cf-87a3-455a-bbdf-c11fb1b60e30","tools":[],"mcp_servers":[{"name":"plugin:human-resources:slack","status":"needs-auth","source":"plugin"},{"name":"plugin:human-resources:google calendar","status":"failed","source":"plugin"},{"name":"plugin:human-resources:gmail","status":"failed","source":"plugin"},{"name":"plugin:human-resources:notion","status":"needs-auth","source":"plugin"},{"name":"plugin:human-resources:atlassian","status":"needs-auth","source":"plugin"},{"name":"plugin:operations:google calendar","status":"failed","source":"plugin"},{"name":"plugin:operations:gmail","status":"failed","source":"plugin"},{"name":"plugin:design:figma","status":"needs-auth","source":"plugin"},{"name":"plugin:design:linear","status":"needs-auth","source":"plugin"},{"name":"plugin:design:intercom","status":"needs-auth","source":"plugin"},{"name":"plugin:design:google calendar","status":"failed","source":"plugin"},{"name":"plugin:design:gmail","status":"failed","source":"plugin"},{"name":"plugin:engineering:datadog","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:google calendar","status":"failed","source":"plugin"},{"name":"plugin:engineering:gmail","status":"failed","source":"plugin"},{"name":"plugin:marketing:canva","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:amplitude","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:amplitude-eu","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:ahrefs","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:similarweb","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:klaviyo","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:supermetrics","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:google calendar","status":"failed","source":"plugin"},{"name":"plugin:marketing:gmail","status":"failed","source":"plugin"},{"name":"plugin:finance:snowflake","status":"failed","source":"plugin"},{"name":"plugin:finance:databricks","status":"failed","source":"plugin"},{"name":"plugin:finance:google calendar","status":"failed","source":"plugin"},{"name":"plugin:finance:gmail","status":"failed","source":"plugin"},{"name":"penpot","status":"failed","source":"user"},{"name":"pencil","status":"failed","source":"user"},{"name":"minimax-image","status":"connected","source":"user"},{"name":"context7","status":"connected","source":"user"},{"name":"playwright","status":"connected","source":"user"},{"name":"sequential-thinking","status":"connected","source":"user"},{"name":"image-compare","status":"connected","source":"user"},{"name":"gitea","status":"connected","source":"user"},{"name":"headroom","status":"connected","source":"user"},{"name":"claude.ai Claude Docs","status":"connected","source":"claudeai"},{"name":"claude.ai Google Calendar","status":"needs-auth","source":"claudeai"},{"name":"claude.ai Google Drive","status":"needs-auth","source":"claudeai"},{"name":"claude.ai Gmail","status":"needs-auth","source":"claudeai"}],"model":"claude-opus-5-5","permissionMode":"default","slash_commands":[],"terminal_slash_commands":[],"apiKeySource":"none","claude_code_version":"2.1.306","output_style":"default","agents":[],"skills":[],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","interrupt_send_now_v1","msg_lifecycle_v1","request_marker_lists_v1","sdk_mcp_tools_list_changed","sdk_mcp_manifests","mcp_read_resource_v1","mcp_tool_ui_meta_v1","ui_surface_v1","mcp_call_result_not_for_model_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"c790b604-7600-4832-91ef-bb0f45467d17","memory_paths":{},"original_cwd":"/home/user/scratch","additional_directories":["/var/folders/wy/29h9v4ps1kg2hldv1rxfbmxh0000gn/T/tmp.6UBt69rTlg"],"fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":true,"view_mode":"default"}
|
||||
{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgG8BwRxwiuFBS3aJEgz7","type":"message","role":"assistant","content":[{"type":"text","text":"Hi there, friend."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":27039,"cache_read_input_tokens":8857,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27039},"output_tokens":6,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"1c1e03cf-87a3-455a-bbdf-c11fb1b60e30","uuid":"13e68528-c945-41e2-95d2-515f08c2fef2","timestamp":"2026-10-21T21:53:06.724Z","request_id":"req_011CgG8BwBaMoTzyCDYL37wW"}
|
||||
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":0,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"out_of_credits","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.48,"resetsAt":0},"seven_day":{"utilization":0.3,"resetsAt":0}}},"uuid":"d4e98e82-8665-439e-9d2d-68944adf8857","session_id":"1c1e03cf-87a3-455a-bbdf-c11fb1b60e30"}
|
||||
{"duration_api_ms":3023,"stop_reason":"end_turn","session_id":"1c1e03cf-87a3-455a-bbdf-c11fb1b60e30","total_cost_usd":0.2182914,"usage":{"input_tokens":2,"cache_creation_input_tokens":27039,"cache_read_input_tokens":8857,"output_tokens":10,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":27039,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":10,"cache_read_input_tokens":8857,"cache_creation_input_tokens":27039,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":27039},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-opus-5-5":{"inputTokens":2,"outputTokens":10,"cacheReadInputTokens":8857,"cacheCreationInputTokens":27039,"webSearchRequests":0,"costUSD":0.2182914,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-opus-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Hi there, friend.","ttft_ms":3080,"type":"result","duration_ms":3101,"uuid":"b56fde46-3bc1-4e71-a658-1caa69bf3b43","ttft_stream_ms":3031,"time_to_request_ms":79,"first_content_frame_ms":3031,"queued_turn_count":0,"result_index":0}
|
||||
@@ -0,0 +1,54 @@
|
||||
{"type":"system","subtype":"init","cwd":"/home/user/scratch","session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","tools":[],"mcp_servers":[{"name":"papercraft","status":"connected","source":"dynamic"}],"model":"claude-opus-5-5","permissionMode":"default","slash_commands":[],"terminal_slash_commands":[],"apiKeySource":"none","claude_code_version":"2.1.306","output_style":"default","agents":[],"skills":[],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","interrupt_send_now_v1","msg_lifecycle_v1","request_marker_lists_v1","sdk_mcp_tools_list_changed","sdk_mcp_manifests","mcp_read_resource_v1","mcp_tool_ui_meta_v1","ui_surface_v1","mcp_call_result_not_for_model_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"8b194102-51ae-433f-90e1-b08af1570ad7","memory_paths":{},"original_cwd":"/home/user/scratch","additional_directories":["/var/folders/wy/29h9v4ps1kg2hldv1rxfbmxh0000gn/T/tmp.6KDeRcqeyX"],"fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":true,"view_mode":"default"}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"12332e92-83a9-4a80-9e58-9e6c9068b07d"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DFhLv4j7v7wK82NJq","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":25400,"cache_read_input_tokens":8858,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25400},"output_tokens":24,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"ac0f43ed-2c4d-45ed-9a0a-a004fa7f27ee","ttft_ms":948,"thinking_display":"updates","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01BWdwx3vf1PmMrvjyjh69sd","name":"ToolSearch","input":{},"caller":{"type":"direct"}}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"d6d81063-f87d-4b71-b96e-81e1433776d7","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"15a59694-0b2e-4d99-a7f4-8000e0f94b2f","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \"select:"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"7071c507-5f42-4226-90af-b4b9c27ca168","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"mcp__papercraft"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"bf15d92c-a069-4930-8266-a4ec878b5684","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"__get_basic_info"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"a81bb603-0eef-49ad-990a-943b93d0ea24","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\", \"max_results\": 1"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"5bfde67b-cead-4c72-827f-a26dfed432b2","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"}"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"42464cf8-3dd0-4096-b10c-1568737b3583","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DFhLv4j7v7wK82NJq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BWdwx3vf1PmMrvjyjh69sd","name":"ToolSearch","input":{"query":"select:mcp__papercraft__get_basic_info","max_results":1},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":25400,"cache_read_input_tokens":8858,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25400},"output_tokens":24,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"42b105bd-2ff2-466c-956d-5239f540afa0","timestamp":"2026-10-21T21:53:23.558Z","request_id":"req_011CgG8DFQyLk1HJwCdtATth","wire_tool_inputs":{"toolu_01BWdwx3vf1PmMrvjyjh69sd":{"query":"select:mcp__papercraft__get_basic_info","max_results":1}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"deee86a2-11dd-4611-9147-9bd923dfbb4d","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01BWdwx3vf1PmMrvjyjh69sd","content":[{"type":"tool_reference","tool_name":"mcp__papercraft__get_basic_info"}]}]},"parent_tool_use_id":null,"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"139d4d4a-c280-471b-ad50-24d80ab1af70","timestamp":"2026-10-21T21:53:23.565Z","tool_use_result":{"matches":["mcp__papercraft__get_basic_info"],"query":"select:mcp__papercraft__get_basic_info","total_deferred_tools":76},"tool_result_meta":[{"id":"toolu_01BWdwx3vf1PmMrvjyjh69sd","permission_decision":{"decision":"accept","source":"config"}}]}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":25400,"cache_read_input_tokens":8858,"output_tokens":89,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":89,"cache_read_input_tokens":8858,"cache_creation_input_tokens":25400,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":25400},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"1670a4b5-8edc-4afe-aa02-5cb0da03751d","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"058e9ee8-a96f-4a9f-87a0-4f9acc021b3b","api_message_id":"msg_011CgG8DFhLv4j7v7wK82NJq"}
|
||||
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":0,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"out_of_credits","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.49,"resetsAt":0},"seven_day":{"utilization":0.3,"resetsAt":0}}},"uuid":"a1a95689-1c3a-4a58-87e3-7c23eda3e805","session_id":"80bbae9b-4994-482a-8e2b-09d43252df35"}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"0e1fd2b6-fc36-4ee9-ac43-610f7d2c498f"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DQQTgM2ytvmBmnaBe","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":425,"cache_read_input_tokens":34258,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":425},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"84f63134-19bc-4f75-a54e-4a89b9d7c1c2","ttft_ms":906,"thinking_display":"updates","api_message_id":"msg_011CgG8DQQTgM2ytvmBmnaBe"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_016KNvJorupVzfNRMmBfQ65H","name":"mcp__papercraft__get_basic_info","input":{},"caller":{"type":"direct"}}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"dfd2fd2b-21f1-4fc5-aef1-a5a98acd81d0","api_message_id":"msg_011CgG8DQQTgM2ytvmBmnaBe"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"f32535b3-9e81-4034-9518-b5fa93b622ff","api_message_id":"msg_011CgG8DQQTgM2ytvmBmnaBe"}
|
||||
{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DQQTgM2ytvmBmnaBe","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016KNvJorupVzfNRMmBfQ65H","name":"mcp__papercraft__get_basic_info","input":{},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":425,"cache_read_input_tokens":34258,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":425},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"f83c26f2-0175-42a9-96ae-c0185b131566","timestamp":"2026-10-21T21:53:24.525Z","request_id":"req_011CgG8DQ8qyJJ7XMPDihYps","wire_tool_inputs":{"toolu_016KNvJorupVzfNRMmBfQ65H":{}},"tool_use_meta":[{"id":"toolu_016KNvJorupVzfNRMmBfQ65H","display_name":"Get Basic Info","server_display_name":"PaperCraft","tool_name":"get_basic_info"}]}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"40f8493f-da83-419e-be90-1525e240d5d2","api_message_id":"msg_011CgG8DQQTgM2ytvmBmnaBe"}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":425,"cache_read_input_tokens":34258,"output_tokens":38,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":38,"cache_read_input_tokens":34258,"cache_creation_input_tokens":425,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":425},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"765fa4ec-e512-4f29-ae4a-1dc64c975bed","api_message_id":"msg_011CgG8DQQTgM2ytvmBmnaBe"}
|
||||
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"13cedf7c-df75-4144-bbce-c4310b2b7999","api_message_id":"msg_011CgG8DQQTgM2ytvmBmnaBe"}
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"No project is open in papercraft (the home screen is showing). Call list_resources and open_file, or create_file then open_file.","is_error":true,"tool_use_id":"toolu_016KNvJorupVzfNRMmBfQ65H"}]},"parent_tool_use_id":null,"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"5a7f0249-fd14-4b4f-a621-f8ed2890c1cc","timestamp":"2026-10-21T21:53:24.546Z","tool_use_result":"Error: No project is open in papercraft (the home screen is showing). Call list_resources and open_file, or create_file then open_file.","tool_result_meta":[{"id":"toolu_016KNvJorupVzfNRMmBfQ65H","permission_decision":{"decision":"accept","source":"config","reason_type":"rule"}}]}
|
||||
{"type":"system","subtype":"status","status":"requesting","session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"5dc586e8-32c5-49b1-b2cd-0abdbdb8212f"}
|
||||
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DUWGn4nzoVN2tLupZ","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":111,"cache_read_input_tokens":34683,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":111},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"b6d296de-4627-4bc6-9e72-be0597f6f370","ttft_ms":803,"thinking_display":"updates","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":"SIG"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"a98612f3-5b36-4821-b626-bb7fd44ee57f","thinking_display":"updates","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"","estimated_tokens":null}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"43037828-89e6-4010-8818-d23a0f5d7167","thinking_display":"updates","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"3cecd9c6-1bd9-49b6-9072-6ce196e4bb35","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DUWGn4nzoVN2tLupZ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"SIG"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":111,"cache_read_input_tokens":34683,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":111},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"58001e96-24ed-42e9-a3a8-c513cf28f431","timestamp":"2026-10-21T21:53:26.325Z","request_id":"req_011CgG8DUFeGJ3tUebtu46Mu","thinking_duration_ms":970,"thinking_display":"updates"}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"c3d69145-a74b-4d15-a0de-a177eb7ed2de","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"3e2f9d42-f86d-4edf-bf67-04aced0a7368","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"I"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"639dbba7-3a62-4a05-b3cc-b7672cba72d7","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" called `get_basic_info"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"ad3500b5-6c34-423c-981c-5787a67f4cec","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"` once, but"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"22d4d0bc-b2e5-449c-bb68-95891c04d7e0","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" no project"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"b2959675-eb9f-4edc-aa35-6fc3075a7fa7","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" is open in"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"81f8bba3-079b-44f8-9758-ee534a041383","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" PaperCraft ("}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"e8950e38-16fb-445e-94f0-cf470294cc7f","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"it's on"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"091285cb-b812-42e7-959a-897d26b9c1cb","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" the home screen), so there"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"326eefc6-cb5a-4de3-897f-387f9286a696","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"'s no file name to give"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"d8b34ac3-4b11-4d22-942e-adf5662ca125","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" you"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"b0c37759-a14e-45c6-bd85-6dc34a90874f","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":".\n\nTo get"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"5d527d86-7b7f-48da-b87f-388c73e3bbb6","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" one, open a project in"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"71164511-39d8-481d-ba37-12f144de2626","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" the app,"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"50b5cd9d-9e8b-42f6-9b3d-45e65220b101","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" or I can call `list_res"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"e6d29649-2718-4a16-8f51-164460ec1f2a","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ources` and `open_file`"}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"5f55ee7b-20a8-4a28-b2f1-2033908a7c41","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"."}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"d9aa3c9e-4d81-4042-bbbe-87ce6338196e","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgG8DUWGn4nzoVN2tLupZ","type":"message","role":"assistant","content":[{"type":"text","text":"I called `get_basic_info` once, but no project is open in PaperCraft (it's on the home screen), so there's no file name to give you.\n\nTo get one, open a project in the app, or I can call `list_resources` and `open_file`."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":111,"cache_read_input_tokens":34683,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":111},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","uuid":"c8b7625e-6839-4eef-a21b-640e87984e41","timestamp":"2026-10-21T21:53:26.328Z","request_id":"req_011CgG8DUFeGJ3tUebtu46Mu"}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":1},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"7595ec54-7210-4d9e-94fc-a8d62081be84","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":111,"cache_read_input_tokens":34683,"output_tokens":89,"output_tokens_details":{"thinking_tokens":15},"iterations":[{"input_tokens":2,"output_tokens":89,"cache_read_input_tokens":34683,"cache_creation_input_tokens":111,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":111},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"ba3e06bd-5771-4037-a599-427964b968ff","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","parent_tool_use_id":null,"uuid":"427b2b99-0fa5-4b4c-856a-95ba0fa39a26","api_message_id":"msg_011CgG8DUWGn4nzoVN2tLupZ"}
|
||||
{"duration_api_ms":4789,"stop_reason":"end_turn","session_id":"80bbae9b-4994-482a-8e2b-09d43252df35","total_cost_usd":0.22739179999999998,"usage":{"input_tokens":6,"cache_creation_input_tokens":25936,"cache_read_input_tokens":77799,"output_tokens":216,"output_tokens_details":{"thinking_tokens":15},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":25936,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":89,"cache_read_input_tokens":34683,"cache_creation_input_tokens":111,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":111},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-opus-5-5":{"inputTokens":6,"outputTokens":216,"cacheReadInputTokens":77799,"cacheCreationInputTokens":25936,"webSearchRequests":0,"costUSD":0.22739179999999998,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":15,"canonicalModel":"claude-opus-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":3,"subtype":"success","api_error_status":null,"result":"I called `get_basic_info` once, but no project is open in PaperCraft (it's on the home screen), so there's no file name to give you.\n\nTo get one, open a project in the app, or I can call `list_resources` and `open_file`.","ttft_ms":2102,"type":"result","duration_ms":4887,"uuid":"d94a2146-9bfe-450b-a09f-77cb13a685f4","ttft_stream_ms":1026,"time_to_request_ms":79,"first_content_frame_ms":1027,"queued_turn_count":0,"result_index":0}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"type":"thread.started","thread_id":"01a16076-15a4-71c0-8cfa-df664e87bdd2"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hi there, friend!"}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":26064,"cached_input_tokens":12288,"cache_write_input_tokens":0,"output_tokens":9,"reasoning_output_tokens":0}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{"type":"thread.started","thread_id":"synthetic-thread"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Plan the frame first."}}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"ls","aggregated_output":"a.txt\n","exit_code":0,"status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_2","type":"mcp_tool_call","server":"papercraft","tool":"create_artboard","arguments":{"name":"Home"},"result":null,"error":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"mcp_tool_call","server":"papercraft","tool":"create_artboard","arguments":{"name":"Home"},"result":null,"error":{"message":"denied"},"status":"failed"}}
|
||||
{"type":"error","message":"stream disconnected, retrying"}
|
||||
{"type":"turn.failed","error":{"message":"rate limited"}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"type":"thread.started","thread_id":"01a16076-27b1-7291-8bd3-f47391424c74"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"I’ll call `get_basic_info` once and return file name."}}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"mcp_tool_call","server":"papercraft","tool":"get_basic_info","arguments":{},"result":null,"error":null,"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"mcp_tool_call","server":"papercraft","tool":"get_basic_info","arguments":{},"result":{"content":[{"type":"text","text":"No project is open in papercraft (the home screen is showing). Call list_resources and open_file, or create_file then open_file."}],"structured_content":null},"error":null,"status":"failed"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"No file open in PaperCraft."}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":79922,"cached_input_tokens":64896,"cache_write_input_tokens":0,"output_tokens":111,"reasoning_output_tokens":0}}
|
||||
@@ -0,0 +1,14 @@
|
||||
data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Creating it."},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_A","type":"function","function":{"name":"create_artboard","arguments":"{\"name\":\"Home\","}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_B","type":"function","function":{"name":"get_basic_info","arguments":"{}"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"styles\":{\"width\":\"1440px\",\"height\":\"900px\"}}"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
|
||||
|
||||
data: {"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":20}}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
data: {"id":"c2","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Done: "},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c2","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"board ready."},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c2","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: {"id":"c2","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":160,"completion_tokens":12}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
: SYNTHETIC fixture, hand-written from the documented Chat Completions streaming shape.
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"Need an artboard."},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Creating "},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"the home screen."},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_A","type":"function","function":{"name":"create_artboard","arguments":""}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"name\":\"Ho"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_B","type":"function","function":{"name":"get_basic_info","arguments":""}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"me\",\"width\":1440}"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{}"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":1020,"completion_tokens":87,"total_tokens":1107,"prompt_tokens_details":{"cached_tokens":900}}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
@@ -5,6 +5,10 @@ use craft_ui::theme::catppuccin::Flavor;
|
||||
use egui::{Pos2, Rect, Vec2};
|
||||
use papercraft::editor::Editor;
|
||||
use papercraft::theme::{self, Choice};
|
||||
use papercraft::chat::events::{Event, Turn};
|
||||
use papercraft::chat::{Message, Role};
|
||||
use papercraft::runs::{Run, RunState};
|
||||
use papercraft::ui::chat::{Approval, Detect, EndpointState, TurnHandle};
|
||||
use papercraft::ui::{self, UiState, home};
|
||||
|
||||
fn frame(ctx: &egui::Context, mut f: impl FnMut(&mut egui::Ui)) {
|
||||
@@ -174,3 +178,130 @@ fn interface_scale_changes_the_point_size_and_still_draws() {
|
||||
assert!((ctx.pixels_per_point() / before - 1.25).abs() < 1e-3);
|
||||
theme::apply_ui_scale(&ctx, 1.0, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_mode_draws_with_inspector_overlay() {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-chat-ui-{:016x}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (w, h) in [(1280.0, 800.0), (2560.0, 1440.0)] {
|
||||
let ctx = egui::Context::default();
|
||||
ui::setup_style(&ctx);
|
||||
let mut ed = Editor::default();
|
||||
let mut st = UiState { project_dir: Some(dir.clone()), ..UiState::default() };
|
||||
st.chat.project = Some(dir.clone()); // as if the project was already seen
|
||||
st.chat.detect = Some(Detect::default()); // no scan (it starts CLIs and reads the keychain)
|
||||
for inspector in [false, true] {
|
||||
// Set the fields, not `set_chat_mode`: that would write the user's chat prefs.
|
||||
st.chat_mode = true;
|
||||
st.chat.inspector = inspector;
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(w, h))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
ui::draw(ui, &mut ed, &mut st);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
assert!(st.chat.chat.is_some(), "an empty chat is opened");
|
||||
assert!(papercraft::chat::store::list(&dir).is_empty(), "nothing saved before the first message");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
struct FakeTurn;
|
||||
|
||||
impl Turn for FakeTurn {
|
||||
fn stop(&mut self) {}
|
||||
fn is_running(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_ui(dir: &std::path::Path) -> UiState {
|
||||
let mut st = UiState { project_dir: Some(dir.to_path_buf()), ..UiState::default() };
|
||||
st.chat.project = Some(dir.to_path_buf());
|
||||
st.chat.detect = Some(Detect { claude: Some(("/bin/claude".into(), "2.3.1".into())), endpoints: vec![EndpointState { id: "anthropic".into(), key: true, ready: true }], default_endpoint: "anthropic".into(), ..Detect::default() });
|
||||
st.chat_mode = true;
|
||||
st
|
||||
}
|
||||
|
||||
fn draw_chat(st: &mut UiState, w: f32, h: f32) {
|
||||
let ctx = egui::Context::default();
|
||||
ui::setup_style(&ctx);
|
||||
let mut ed = Editor::default();
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(w, h))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
ui::draw(ui, &mut ed, st);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(st: &mut UiState, id: &str, name: &str, args: serde_json::Value, done: Option<Result<serde_json::Value, &str>>) {
|
||||
st.chat.apply(Event::ToolStart { id: id.into(), name: name.into(), args });
|
||||
match done {
|
||||
Some(Ok(r)) => st.chat.apply(Event::ToolEnd { id: id.into(), result: Some(r), error: None }),
|
||||
Some(Err(e)) => st.chat.apply(Event::ToolEnd { id: id.into(), result: None, error: Some(e.into()) }),
|
||||
None => false,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_states_draw_from_recorded_events() {
|
||||
use serde_json::json;
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-chat-states-{:016x}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// The 1x1 PNG a screenshot tool would return.
|
||||
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
let shot = json!([{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": png}}]);
|
||||
|
||||
// empty, setup, picker
|
||||
let mut st = chat_ui(&dir);
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
st.chat.picker_open = true;
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
st.chat.detect = Some(Detect::default());
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
|
||||
// streaming: reasoning, grouped tool cards (one running, one failed), a screenshot, the plan
|
||||
let mut st = chat_ui(&dir);
|
||||
st.chat.ensure_chat();
|
||||
st.chat.chat.as_mut().unwrap().messages.push(Message { role: Role::User, text: "[Selection] Card (ab12cd:3)\n\nBuild it".into(), at: 10, ..Default::default() });
|
||||
st.chat.apply(Event::Thinking("Planning the card".into()));
|
||||
st.chat.apply(Event::Text("I will build **the card**:\n- one\n- two `x`\n```html\n<div/>\n```".into()));
|
||||
tool(&mut st, "1", "create_artboard", json!({"name": "Test"}), Some(Ok(json!([{"type": "text", "text": "{\"nodeId\":\"ab12cd:4\"}"}]))));
|
||||
tool(&mut st, "2", "get_screenshot", json!({"nodeId": "ab12cd:4"}), Some(Ok(shot)));
|
||||
tool(&mut st, "3", "update_styles", json!({"nodeId": "ab12cd:5"}), Some(Err("Node ab12cd:5 not found")));
|
||||
tool(&mut st, "4", "TodoWrite", json!({"todos": [{"content": "A", "status": "completed"}, {"content": "B", "status": "in_progress"}, {"content": "C", "status": "pending"}]}), Some(Ok(json!("ok"))));
|
||||
tool(&mut st, "5", "write_html", json!({"html": "x ".repeat(900)}), None);
|
||||
st.chat.turn = Some(TurnHandle::Cli(Box::new(FakeTurn)));
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
draw_chat(&mut st, 2560.0, 1440.0);
|
||||
|
||||
// waiting for approval
|
||||
st.chat.approvals.push(Approval { id: "5".into(), name: "delete_pages".into(), args: json!({"name": "Landing"}) });
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
|
||||
// finished: usage line, checkpoint, run card, suggestions, error row
|
||||
let mut st = chat_ui(&dir);
|
||||
st.chat.ensure_chat();
|
||||
st.chat.chat.as_mut().unwrap().messages.push(Message { role: Role::User, text: "Build it".into(), at: 10, ..Default::default() });
|
||||
st.chat.apply(Event::Text("Done.".into()));
|
||||
tool(&mut st, "1", "create_artboard", json!({"name": "Test"}), Some(Ok(json!("ok"))));
|
||||
tool(&mut st, "2", "lint_design", json!({}), Some(Ok(json!("ok"))));
|
||||
st.chat.apply(Event::TurnEnd {
|
||||
usage: Some(papercraft::chat::Usage { input: 12_400, output: 1_100, cache_read: 9_800, cache_write: 0, cost_usd: Some(0.06) }),
|
||||
session: None,
|
||||
stop_reason: None,
|
||||
});
|
||||
st.runs.push(Run { id: "r1".into(), number: 3, started: 20, ended: Some(12_020), calls: 2, added: 6, state: RunState::Review, ..Default::default() });
|
||||
st.runs_key = Some(dir.clone());
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
st.chat.apply(Event::Error("boom".into()));
|
||||
draw_chat(&mut st, 1280.0, 800.0);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||