From 2d069ca1860b893ef6463b0b1d47704ed3956675 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 14:22:38 -0400 Subject: [PATCH] In-app agent chat: your own coding agents over ACP, plus OpenAI-compatible endpoints (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Agent chat prototype: drive an ACP agent the user already has from a side panel A desktop-only chat panel (off by default) that starts a coding agent over the Agent Client Protocol and gives it SolidCraft's MCP server, bridged to the open window through the token-protected control channel with the default folder limits. It finds installed agents (Claude Code and Codex adapters, Gemini CLI, Copilot, Qwen, OpenCode, goose) with version and login state, streams text, tool calls and plans, shows the agent's permission requests, and when an agent isn't signed in shows its own login methods without handling any credential. Design notes and findings: docs/design/in-app-agent-chat.md. Co-Authored-By: Claude Opus 5.5 * docs: say only what the end-to-end run showed Co-Authored-By: Claude Opus 5.5 * Agent chat: gate, one undo per message, docking, process groups, discovery polish Co-Authored-By: Claude Opus 5.5 * Agent chat: Settings → AI, several agents with a switcher, OpenAI-compatible endpoints, permission modes, toolbar button Co-Authored-By: Claude Opus 5.5 * Agent chat: translations, tool cards, Add agent dialog fixes, test agent saves on its own Co-Authored-By: Claude Opus 5.5 * Agent chat: third-party licences for the keychain crates; clippy Co-Authored-By: Claude Opus 5.5 * Agent chat: badge text from the theme tokens Co-Authored-By: Claude Opus 5.5 * Agent chat: Auto-allow model edits is the default permission mode Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 427 ++++++- ROADMAP.md | 7 + THIRD-PARTY-LICENSES.txt | 1259 +++++++++++++++---- apps/solidcraft/Cargo.toml | 3 + apps/solidcraft/src/control_server.rs | 54 +- apps/solidcraft/src/main.rs | 73 ++ assets/icons/ui/source/icons.rs | 7 + assets/locales/de.json | 141 +++ assets/locales/en.json | 141 +++ assets/locales/ja.json | 141 +++ assets/locales/ko.json | 141 +++ assets/locales/zh.json | 141 +++ crates/ui-egui/examples/acp_test_agent.rs | 226 ++++ crates/ui-egui/src/agent_chat/acp.rs | 499 ++++++++ crates/ui-egui/src/agent_chat/acp_client.rs | 246 ++++ crates/ui-egui/src/agent_chat/agents.rs | 234 ++++ crates/ui-egui/src/agent_chat/api_client.rs | 485 +++++++ crates/ui-egui/src/agent_chat/backend.rs | 154 +++ crates/ui-egui/src/agent_chat/discover.rs | 316 +++++ crates/ui-egui/src/agent_chat/gate.rs | 219 ++++ crates/ui-egui/src/agent_chat/mod.rs | 997 +++++++++++++++ crates/ui-egui/src/agent_chat/panel.rs | 761 +++++++++++ crates/ui-egui/src/agent_chat/process.rs | 318 +++++ crates/ui-egui/src/agent_chat/settings.rs | 541 ++++++++ crates/ui-egui/src/agent_chat/tests.rs | 305 +++++ crates/ui-egui/src/control.rs | 16 + crates/ui-egui/src/keymap.rs | 2 +- crates/ui-egui/src/lib.rs | 20 +- crates/ui-egui/src/prefs.rs | 50 +- crates/ui-egui/src/toolbar.rs | 44 +- crates/ui-egui/src/viewport.rs | 11 + docs/agent-chat.md | 181 +++ docs/book/src/SUMMARY.md | 1 + docs/book/src/guide/agent-chat.md | 1 + docs/control-protocol.md | 3 +- docs/design/in-app-agent-chat.md | 216 ++++ 36 files changed, 8078 insertions(+), 303 deletions(-) create mode 100644 crates/ui-egui/examples/acp_test_agent.rs create mode 100644 crates/ui-egui/src/agent_chat/acp.rs create mode 100644 crates/ui-egui/src/agent_chat/acp_client.rs create mode 100644 crates/ui-egui/src/agent_chat/agents.rs create mode 100644 crates/ui-egui/src/agent_chat/api_client.rs create mode 100644 crates/ui-egui/src/agent_chat/backend.rs create mode 100644 crates/ui-egui/src/agent_chat/discover.rs create mode 100644 crates/ui-egui/src/agent_chat/gate.rs create mode 100644 crates/ui-egui/src/agent_chat/mod.rs create mode 100644 crates/ui-egui/src/agent_chat/panel.rs create mode 100644 crates/ui-egui/src/agent_chat/process.rs create mode 100644 crates/ui-egui/src/agent_chat/settings.rs create mode 100644 crates/ui-egui/src/agent_chat/tests.rs create mode 100644 docs/agent-chat.md create mode 100644 docs/book/src/guide/agent-chat.md create mode 100644 docs/design/in-app-agent-chat.md diff --git a/Cargo.lock b/Cargo.lock index d8d552ab..ed1428ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ dependencies = [ "atspi-common", "phf", "serde", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -86,7 +86,7 @@ dependencies = [ "futures-lite", "futures-util", "serde", - "zbus", + "zbus 5.19.0", ] [[package]] @@ -123,6 +123,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", +] + [[package]] name = "ahash" version = "0.8.12" @@ -280,6 +291,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -398,11 +420,11 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus", + "zbus 5.19.0", "zbus-lockstep", "zbus-lockstep-macros", - "zbus_names", - "zvariant", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -413,7 +435,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", - "zbus", + "zbus 5.19.0", ] [[package]] @@ -467,6 +489,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" @@ -533,6 +564,12 @@ dependencies = [ "syn 3.0.6", ] +[[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" @@ -595,6 +632,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.6.0" @@ -652,6 +698,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" @@ -821,6 +877,35 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[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 = "derive_arbitrary" version = "1.5.0" @@ -853,6 +938,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1322,6 +1408,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1335,8 +1427,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1594,6 +1689,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -1764,6 +1877,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[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 = "itertools" version = "0.13.0" @@ -1878,6 +2001,23 @@ dependencies = [ "syn 2.0.119", ] +[[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", + "zbus 4.4.0", + "zeroize", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -1913,6 +2053,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" @@ -2123,12 +2272,49 @@ 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 = "nohash-hasher" 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" @@ -2138,6 +2324,36 @@ 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" @@ -3098,7 +3314,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3125,7 +3341,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3184,6 +3400,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[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" @@ -3282,6 +3530,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3453,6 +3712,7 @@ dependencies = [ "embed-resource", "getrandom 0.3.4", "hidapi", + "keyring", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -5084,6 +5344,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" @@ -5140,6 +5410,44 @@ 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-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.19.0" @@ -5170,9 +5478,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -5182,7 +5490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -5196,7 +5504,20 @@ dependencies = [ "syn 2.0.119", "zbus-lockstep", "zbus_xml", - "zvariant", + "zvariant 5.15.0", +] + +[[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 2.1.0", ] [[package]] @@ -5209,9 +5530,20 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[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 4.2.0", ] [[package]] @@ -5222,7 +5554,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -5233,8 +5565,8 @@ checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ "serde", "winnow", - "zbus_names", - "zvariant", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -5292,6 +5624,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" @@ -5379,6 +5725,19 @@ 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 4.2.0", +] + [[package]] name = "zvariant" version = "5.15.0" @@ -5390,8 +5749,21 @@ dependencies = [ "serde", "winnow", "zcheapstr", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[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 2.1.0", ] [[package]] @@ -5404,7 +5776,18 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "zvariant_utils", + "zvariant_utils 4.2.0", +] + +[[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", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index ef549430..1cc2dc12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -222,6 +222,13 @@ retain their saved two-finger scroll preference; camera motion is available thro Crash Reports…) with a reviewed issue draft; agents read them through `diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent anywhere. See [crash reports](docs/crash-reports.md). +- Agent chat (off by default, desktop): a side panel that drives a coding agent the user + already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, …) over the + Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works + through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or + Deny, crash reports are off limits, 24 changes per message, one undo step per message, and + the agent's whole process group ends with the panel or the app. Docks left or right, + minimises to a strip. See [agent chat](docs/agent-chat.md). - Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`) writes a design's history as a script that `eval` and `run` replay into the same design — user parameters, units, sheet metal and plastic rules and materials as their own commands, diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index b67312db..f9063778 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolidCraft -SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 477 crates below. +SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 508 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -14,6 +14,7 @@ accesskit_unix 0.21.1 (MIT OR Apache-2.0) accesskit_windows 0.32.1 (MIT OR Apache-2.0) accesskit_winit 0.32.2 (Apache-2.0) adler2 2.0.1 (0BSD OR MIT OR Apache-2.0) +aes 0.8.4 (MIT OR Apache-2.0) ahash 0.8.12 (MIT OR Apache-2.0) allocator-api2 0.2.21 (MIT OR Apache-2.0) android-activity 0.6.1 (MIT OR Apache-2.0) @@ -29,6 +30,7 @@ ash 0.38.0+1.3.281 (MIT OR Apache-2.0) async-broadcast 0.7.2 (MIT OR Apache-2.0) async-channel 2.5.0 (Apache-2.0 OR MIT) async-executor 1.14.0 (Apache-2.0 OR MIT) +async-fs 2.2.0 (Apache-2.0 OR MIT) async-io 2.6.0 (Apache-2.0 OR MIT) async-lock 3.4.2 (Apache-2.0 OR MIT) async-process 2.5.0 (Apache-2.0 OR MIT) @@ -46,6 +48,7 @@ bit-vec 0.9.1 (Apache-2.0 OR MIT) bitflags 1.3.2 (MIT/Apache-2.0) bitflags 2.13.2 (MIT OR Apache-2.0) block-buffer 0.10.4 (MIT OR Apache-2.0) +block-padding 0.3.3 (MIT OR Apache-2.0) block2 0.5.1 (MIT) block2 0.6.2 (MIT) blocking 1.7.0 (Apache-2.0 OR MIT) @@ -53,16 +56,19 @@ branches 0.3.0 (MIT) bumpalo 3.20.3 (MIT OR Apache-2.0) bytemuck 1.25.2 (Zlib OR Apache-2.0 OR MIT) bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT) +byteorder 1.5.0 (Unlicense OR MIT) byteorder-lite 0.1.0 (Unlicense OR MIT) bytes 1.12.1 (MIT) calloop 0.13.0 (MIT) calloop 0.14.5 (MIT) calloop-wayland-source 0.3.0 (MIT) calloop-wayland-source 0.4.1 (MIT) +cbc 0.1.2 (MIT OR Apache-2.0) cfg-if 1.0.5 (MIT OR Apache-2.0) cgl 0.3.2 (MIT / Apache-2.0) cgmath 0.18.0 (Apache-2.0) chrono 0.4.45 (MIT OR Apache-2.0) +cipher 0.4.4 (MIT OR Apache-2.0) clipboard-win 5.4.1 (BSL-1.0) codespan-reporting 0.13.1 (Apache-2.0) color 0.3.3 (Apache-2.0 OR MIT) @@ -82,6 +88,8 @@ crossbeam-utils 0.8.23 (MIT OR Apache-2.0) crunchy 0.2.4 (MIT) crypto-common 0.1.7 (MIT OR Apache-2.0) cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib) +dbus 0.9.12 (Apache-2.0/MIT) +dbus-secret-service 4.1.0 (MIT OR Apache-2.0) derive_arbitrary 1.5.0 (MIT OR Apache-2.0) derive_more 0.99.20 (MIT) digest 0.10.7 (MIT OR Apache-2.0) @@ -127,6 +135,7 @@ futures-core 0.3.34 (MIT OR Apache-2.0) futures-io 0.3.34 (MIT OR Apache-2.0) futures-lite 2.6.1 (Apache-2.0 OR MIT) futures-macro 0.3.34 (MIT OR Apache-2.0) +futures-sink 0.3.34 (MIT OR Apache-2.0) futures-task 0.3.34 (MIT OR Apache-2.0) futures-util 0.3.34 (MIT OR Apache-2.0) generic-array 0.14.7 (MIT) @@ -150,6 +159,8 @@ hashbrown 0.17.1 (MIT OR Apache-2.0) hermit-abi 0.5.3 (MIT OR Apache-2.0) hex 0.4.3 (MIT OR Apache-2.0) hidapi 2.6.7 (MIT) +hkdf 0.12.4 (MIT OR Apache-2.0) +hmac 0.12.1 (MIT OR Apache-2.0) http 1.5.0 (MIT OR Apache-2.0) httparse 1.10.1 (MIT OR Apache-2.0) iana-time-zone 0.1.65 (MIT OR Apache-2.0) @@ -165,6 +176,7 @@ idna 1.1.0 (MIT OR Apache-2.0) idna_adapter 1.2.2 (Apache-2.0 OR MIT) image 0.25.10 (MIT OR Apache-2.0) indexmap 2.14.2 (Apache-2.0 OR MIT) +inout 0.1.4 (MIT OR Apache-2.0) itertools 0.13.0 (MIT OR Apache-2.0) itertools 0.15.0 (MIT OR Apache-2.0) itoa 1.0.18 (MIT OR Apache-2.0) @@ -175,9 +187,11 @@ jni-sys 0.4.1 (MIT OR Apache-2.0) jni-sys-macros 0.4.1 (MIT OR Apache-2.0) js-sys 0.3.106 (MIT OR Apache-2.0) katexit 0.1.5 (MIT OR Apache-2.0) +keyring 3.6.3 (MIT OR Apache-2.0) khronos-egl 6.0.0 (MIT/Apache-2.0) kurbo 0.13.1 (Apache-2.0 OR MIT) libc 0.2.190 (MIT OR Apache-2.0) +libdbus-sys 0.2.7 (Apache-2.0/MIT) libloading 0.8.9 (ISC) libm 0.2.16 (MIT) libredox 0.1.25 (MIT) @@ -201,8 +215,14 @@ naga-types 30.0.1 (MIT OR Apache-2.0) ndk 0.9.0 (MIT OR Apache-2.0) ndk-context 0.1.1 (MIT OR Apache-2.0) ndk-sys 0.6.0+11769913 (MIT OR Apache-2.0) +nix 0.29.0 (MIT) nohash-hasher 0.2.0 (Apache-2.0 OR MIT) +num 0.4.3 (MIT OR Apache-2.0) +num-bigint 0.4.8 (MIT OR Apache-2.0) num-complex 0.4.6 (MIT OR Apache-2.0) +num-integer 0.1.47 (MIT OR Apache-2.0) +num-iter 0.1.46 (MIT OR Apache-2.0) +num-rational 0.4.2 (MIT OR Apache-2.0) num-traits 0.2.19 (MIT OR Apache-2.0) num_enum 0.7.6 (BSD-3-Clause OR MIT OR Apache-2.0) num_enum_derive 0.7.6 (BSD-3-Clause OR MIT OR Apache-2.0) @@ -304,6 +324,8 @@ rustversion 1.0.23 (MIT OR Apache-2.0) schannel 0.1.29 (MIT) scoped-tls 1.0.1 (MIT/Apache-2.0) scopeguard 1.2.0 (MIT OR Apache-2.0) +secret-service 4.0.0 (MIT OR Apache-2.0) +security-framework 2.11.1 (MIT OR Apache-2.0) security-framework 3.7.0 (MIT OR Apache-2.0) security-framework-sys 2.17.0 (MIT OR Apache-2.0) self_cell 1.3.0 (Apache-2.0 OR GPL-2.0-only) @@ -313,6 +335,7 @@ serde_core 1.0.229 (MIT OR Apache-2.0) serde_derive 1.0.229 (MIT OR Apache-2.0) serde_json 1.0.151 (MIT OR Apache-2.0) serde_repr 0.1.21 (MIT OR Apache-2.0) +sha1 0.10.7 (MIT OR Apache-2.0) sha2 0.10.9 (MIT OR Apache-2.0) signal-hook-registry 1.4.8 (MIT OR Apache-2.0) simd-adler32 0.3.10 (MIT) @@ -453,14 +476,18 @@ x11-dl 2.21.0 (MIT) x11rb 0.13.2 (MIT OR Apache-2.0) x11rb-protocol 0.13.2 (MIT OR Apache-2.0) xcursor 0.3.11 (MIT) +xdg-home 1.3.0 (MIT) xkbcommon-dl 0.4.2 (MIT) xkeysym 0.2.1 (MIT OR Apache-2.0 OR Zlib) yoke 0.8.3 (Unicode-3.0) yoke-derive 0.8.2 (Unicode-3.0) +zbus 4.4.0 (MIT) zbus 5.19.0 (MIT) zbus-lockstep 0.5.2 (MIT) zbus-lockstep-macros 0.5.2 (MIT) +zbus_macros 4.4.0 (MIT) zbus_macros 5.19.0 (MIT) +zbus_names 3.0.0 (MIT) zbus_names 4.3.4 (MIT) zbus_xml 5.2.1 (MIT) zcheapstr 1.1.0 (MIT) @@ -469,6 +496,7 @@ zerocopy-derive 0.8.61 (BSD-2-Clause OR Apache-2.0 OR MIT) zerofrom 0.1.8 (Unicode-3.0) zerofrom-derive 0.1.7 (Unicode-3.0) zeroize 1.9.1 (Apache-2.0 OR MIT) +zeroize_derive 1.5.0 (Apache-2.0 OR MIT) zerotrie 0.2.5 (Unicode-3.0) zerovec 0.11.8 (Unicode-3.0) zerovec-derive 0.11.6 (Unicode-3.0) @@ -478,8 +506,11 @@ zmij 1.0.23 (MIT) zopfli 0.8.3 (Apache-2.0) zune-core 0.5.3 (MIT OR Apache-2.0 OR Zlib) zune-jpeg 0.5.15 (MIT OR Apache-2.0 OR Zlib) +zvariant 4.2.0 (MIT) zvariant 5.15.0 (MIT) +zvariant_derive 4.2.0 (MIT) zvariant_derive 5.15.0 (MIT) +zvariant_utils 2.1.0 (MIT) zvariant_utils 4.2.0 (MIT) ================================================================================ @@ -500,12 +531,12 @@ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ -adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, -async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task 4.7.1, -async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, displaydoc 0.2.7, -endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, -futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0, linux-raw-sys -0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, +adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-fs 2.2.0, +async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, +async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, +displaydoc 0.2.7, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4, +fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0, +linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic 1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47, rustc-hash 1.1.0, rustc-hash @@ -513,8 +544,9 @@ pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic 1.15.0, po 1.0.229, serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi -0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, -zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zmij 1.0.23, zvariant_utils 4.2.0 +0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home +1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, +zvariant_utils 2.1.0, zvariant_utils 4.2.0 ================================================================================ Permission is hereby granted, free of charge, to any @@ -747,6 +779,243 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +================================================================================ +aes 0.8.4 +================================================================================ + +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +aes 0.8.4, block-buffer 0.10.4, block-padding 0.3.3, cbc 0.1.2, cipher 0.4.4, cpufeatures 0.2.17, +crypto-common 0.1.7, digest 0.10.7, hkdf 0.12.4, hmac 0.12.1, inout 0.1.4, sha1 0.10.7, sha2 0.10.9 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ================================================================================ ahash 0.8.12 ================================================================================ @@ -778,28 +1047,30 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-io -2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task -4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo -3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation -0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque -0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, derive_arbitrary 1.5.0, displaydoc 0.2.7, -either 1.19.0, equivalent 1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, -event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, form_urlencoded 1.2.2, futures-lite -2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, -httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools -0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api -0.4.14, log 0.4.34, num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, -ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding -2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon -1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, rustc-hash 1.1.0, rustix -0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard -1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, -simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile -3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, -wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, -wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, -wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1 +ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-fs +2.2.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal +0.2.14, async-task 4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, +blocking 1.7.0, bumpalo 3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation +0.10.1, core-foundation 0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types +0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service +4.1.0, derive_arbitrary 1.5.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14, +euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, +form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, +hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap +2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, +linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, +num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, +openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core +0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster +0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron +0.12.2, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, +scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0, security-framework 2.11.1, +security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 +1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, +ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi +0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures +0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, +web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1 ================================================================================ Apache License @@ -1504,7 +1775,7 @@ approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, at cgmath 0.18.0, codespan-reporting 0.13.1, kurbo 0.13.1, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions 1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, -x11rb-protocol 0.13.2, zeroize 1.9.1, zune-core 0.5.3, zune-jpeg 0.5.15 +x11rb-protocol 0.13.2, zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15 ================================================================================ Apache License @@ -2517,7 +2788,8 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -bitflags 1.3.2, bitflags 2.13.2, log 0.4.34, num-complex 0.4.6, num-traits 0.2.19 +bitflags 1.3.2, bitflags 2.13.2, log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, +num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19 ================================================================================ Copyright (c) 2014 The Rust Project Developers @@ -2547,7 +2819,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -block-buffer 0.10.4 +block-buffer 0.10.4, block-padding 0.3.3 ================================================================================ Copyright (c) 2018-2019 The RustCrypto Project Developers @@ -2576,212 +2848,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -================================================================================ -block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9 -================================================================================ - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - ================================================================================ branches 0.3.0, rclite 0.2.8 ================================================================================ @@ -2935,7 +3001,7 @@ The above copyright notice and this permission notice (including the next paragr THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1 +byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1 ================================================================================ The MIT License (MIT) @@ -2961,7 +3027,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 +byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 ================================================================================ This is free and unencumbered software released into the public domain. @@ -2989,6 +3055,14 @@ OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to +================================================================================ +byteorder 1.5.0, memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 +================================================================================ + +This project is dual-licensed under the Unlicense and MIT licenses. + +You may use this code under the terms of either license. + ================================================================================ bytes 1.12.1 ================================================================================ @@ -3067,6 +3141,37 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +cbc 0.1.2 +================================================================================ + +Copyright (c) 2018-2022 RustCrypto Developers +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ cfg-if 1.0.5, js-sys 0.3.106, openssl-probe 0.2.1, scoped-tls 1.0.1, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, @@ -3384,6 +3489,36 @@ See the License for the specific language governing permissions and limitations under the License. ~~~~ +================================================================================ +cipher 0.4.4 +================================================================================ + +Copyright (c) 2016-2020 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ combine 4.6.8 ================================================================================ @@ -4008,6 +4143,266 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +dbus 0.9.12, libdbus-sys 0.2.7 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2014-2018 David Henningsson and other contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ +dbus 0.9.12, libdbus-sys 0.2.7 +================================================================================ + +Copyright (c) 2014-2018 David Henningsson and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +dbus-secret-service 4.1.0, secret-service 4.0.0 +================================================================================ + +Copyright (c) 2016 secret-service Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ derive_more 0.99.20 ================================================================================ @@ -4035,7 +4430,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -digest 0.10.7 +digest 0.10.7, hmac 0.12.1 ================================================================================ Copyright (c) 2017 Artyom Pavlov @@ -4913,8 +5308,8 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-task 0.3.34, futures-util -0.3.34 +futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-sink 0.3.34, futures-task +0.3.34, futures-util 0.3.34 ================================================================================ Apache License @@ -5121,8 +5516,8 @@ See the License for the specific language governing permissions and limitations under the License. ================================================================================ -futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-task 0.3.34, futures-util -0.3.34 +futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-sink 0.3.34, futures-task +0.3.34, futures-util 0.3.34 ================================================================================ Copyright (c) 2016 Alex Crichton @@ -6121,6 +6516,37 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FO OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +hkdf 0.12.4 +================================================================================ + +Copyright (c) 2015-2018 Vlad Filippov +Copyright (c) 2018-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ http 1.5.0 ================================================================================ @@ -6761,6 +7187,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +inout 0.1.4 +================================================================================ + +Copyright (c) 2022 The RustCrypto Project Developers +Copyright (c) 2022 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ jni-sys 0.3.1, jni-sys 0.4.1 ================================================================================ @@ -6785,6 +7242,242 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +keyring 3.6.3 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2017] [keyring developers] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ +keyring 3.6.3 +================================================================================ + +Copyright (c) 2016 keyring Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ libc 0.2.190 ================================================================================ @@ -7645,14 +8338,6 @@ Apache License See the License for the specific language governing permissions and limitations under the License. -================================================================================ -memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 -================================================================================ - -This project is dual-licensed under the Unlicense and MIT licenses. - -You may use this code under the terms of either license. - ================================================================================ memmap2 0.9.11 ================================================================================ @@ -8321,6 +9006,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +nix 0.29.0 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Carl Lerche + nix-rust Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ nohash-hasher 0.2.0 ================================================================================ @@ -10549,7 +11260,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -security-framework 3.7.0, security-framework-sys 2.17.0 +security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0 ================================================================================ The MIT License (MIT) @@ -10918,7 +11629,7 @@ library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ================================================================================ -sha2 0.10.9 +sha1 0.10.7, sha2 0.10.9 ================================================================================ Copyright (c) 2006-2009 Graydon Hoare @@ -13864,8 +14575,8 @@ Permission is granted to anyone to use this software for any purpose, including 3. This notice may not be removed or altered from any source distribution. ================================================================================ -zbus 5.19.0, zbus_macros 5.19.0, zbus_names 4.3.4, zbus_xml 5.2.1, zvariant 5.15.0, zvariant_derive -5.15.0 +zbus 4.4.0, zbus 5.19.0, zbus_macros 4.4.0, zbus_macros 5.19.0, zbus_names 4.3.4, zbus_xml 5.2.1, +zvariant 4.2.0, zvariant 5.15.0, zvariant_derive 4.2.0, zvariant_derive 5.15.0 ================================================================================ Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors @@ -14219,6 +14930,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +zeroize_derive 1.5.0 +================================================================================ + +Copyright (c) 2019-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ zip 3.0.0 ================================================================================ diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index d870ae03..20e0fa7f 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -36,6 +36,9 @@ getrandom = "0.3" # The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system. ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +# API keys of the agent chat's API agents (docs/agent-chat.md) live in the system keychain: +# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere. +keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] quick-xml = { workspace = true } diff --git a/apps/solidcraft/src/control_server.rs b/apps/solidcraft/src/control_server.rs index 0d79ca6b..2e566afc 100644 --- a/apps/solidcraft/src/control_server.rs +++ b/apps/solidcraft/src/control_server.rs @@ -18,8 +18,9 @@ const MAX_CONNECTIONS: usize = 16; const MAX_BAD_LINES: usize = 8; static OPEN: AtomicUsize = AtomicUsize::new(0); -/// The token file this launch wrote, removed when the app ends. -static TOKEN_FILE: std::sync::OnceLock = std::sync::OnceLock::new(); +/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed +/// when the app ends. +static TOKEN_FILES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); /// A new random token: 32 bytes from the operating system, as hex. fn new_token() -> Option { @@ -30,30 +31,55 @@ fn new_token() -> Option { /// Remove this launch's token file (at exit). pub fn remove_token_file() { - if let Some(p) = TOKEN_FILE.get() { - let _ = std::fs::remove_file(p); + if let Ok(files) = TOKEN_FILES.lock() { + for p in files.iter() { + let _ = std::fs::remove_file(p); + } } } pub fn start(port: u16, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); // Every connection must show the token before anything runs (docs/control-protocol.md). - let Some(token) = control_auth::env_token().or_else(new_token) else { - eprintln!("solidcraft: control server not started: no random token available"); - return rx; + match control_auth::env_token().or_else(new_token) { + Some(token) => { + listen(port, token, tx, ctx, false); + } + None => eprintln!("solidcraft: control server not started: no random token available"), + } + rx +} + +/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback +/// port with a fresh token of its own (never `SOLIDCRAFT_CONTROL_TOKEN`), written to the usual +/// token file, where the MCP server finds it, so the token never passes through the agent. Its +/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow. +pub fn listen_agent(tx: Sender, ctx: egui::Context) -> Option { + let Some(token) = new_token() else { + eprintln!("solidcraft: agent chat endpoint not started: no random token available"); + return None; }; + listen(0, token, tx, ctx, true) +} + +/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on. +/// `patient`: every request may wait as long as a batch (a person decides on it). +fn listen(port: u16, token: String, tx: Sender, ctx: egui::Context, patient: bool) -> Option { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}"); - return rx; + return None; } }; + let port = listener.local_addr().map(|a| a.port()).unwrap_or(port); match control_auth::token_path(port) { Some(path) => match control_auth::write_token_file(&path, &token) { Ok(()) => { eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display()); - let _ = TOKEN_FILE.set(path); + if let Ok(mut files) = TOKEN_FILES.lock() { + files.push(path); + } } Err(e) => eprintln!( "solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \ @@ -78,12 +104,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let ctx = ctx.clone(); let token = token.clone(); std::thread::spawn(move || { - serve(stream, &token, tx, ctx); + serve(stream, &token, tx, ctx, patient); OPEN.fetch_sub(1, Ordering::Relaxed); }); } }); - rx + Some(port) } /// One line without its newline, or `None` at the end of the stream. A client that sends @@ -134,7 +160,7 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader) { let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink()); } -fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { +fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, patient: bool) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; let mut reader = BufReader::new(read); @@ -168,7 +194,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); // A batch replaces many short requests: the whole of it may take long (#743). - let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 }); + let wait = Duration::from_secs(if patient || method == "engine.batch" { 3600 } else { 60 }); let (req, rrx) = ControlRequest::new(method, params); if tx.send(req).is_err() { break; @@ -223,7 +249,7 @@ mod tests { std::thread::spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); - std::thread::spawn(move || serve(stream, token, tx, egui::Context::default())); + std::thread::spawn(move || serve(stream, token, tx, egui::Context::default(), false)); } }); let session = |lines: &[String]| { diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 18b0a285..75f05b7f 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -86,6 +86,8 @@ impl eframe::App for App { storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs()); } fn on_exit(&mut self) { + // The agent chat's agent and everything it started end with the app. + solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve); solidcraft_ui_egui::recovery_ui::close(&mut self.solve); solidcraft_engine::crash::end_session(); // An update asked to restart into the new version or to run its installer. @@ -159,6 +161,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services { http_download: Some(std::sync::Arc::new(http_download)), // Already an installed app: only the browser build offers to install. install_app: None, + agent_http: Some(std::sync::Arc::new(agent_http)), + secrets: Some(std::sync::Arc::new(Keychain)), show_in_folder: Some(Box::new(|path| { let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new(".")); let mut command = std::process::Command::new(if cfg!(target_os = "windows") { @@ -215,6 +219,71 @@ fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option< Ok(()) } +/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints, docs/agent-chat.md): to the +/// URL the user configured, which may be plain HTTP on this computer (Ollama, LM Studio). No +/// redirects (a key must not follow one elsewhere), at most 16 MB back. +fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Result<(u16, Vec), String> { + use std::time::Duration; + let tls = ureq::tls::TlsConfig::builder() + .root_certs(ureq::tls::RootCerts::PlatformVerifier) + .unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider())) + .build(); + let agent: ureq::Agent = ureq::Agent::config_builder() + .tls_config(tls) + .http_status_as_error(false) + .timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900)))) + .timeout_connect(Some(Duration::from_secs(10))) + .user_agent("SolidCraft") + .max_redirects(0) + .build() + .into(); + let mut response = if r.method == "POST" { + let mut req = agent.post(&r.url); + for (k, v) in &r.headers { + req = req.header(k, v); + } + req.send(r.body.as_deref().unwrap_or_default()) + } else { + let mut req = agent.get(&r.url); + for (k, v) in &r.headers { + req = req.header(k, v); + } + req.call() + } + .map_err(|e| e.to_string())?; + let status = response.status().as_u16(); + let body = response.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| e.to_string())?; + Ok((status, body)) +} + +/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent). +struct Keychain; + +impl Keychain { + fn entry(id: &str) -> Result { + keyring::Entry::new("SolidCraft agent chat", id).map_err(|e| e.to_string()) + } +} + +impl solidcraft_ui_egui::agent_chat::backend::Secrets for Keychain { + fn get(&self, id: &str) -> Result, String> { + match Self::entry(id)?.get_password() { + Ok(p) => Ok(Some(p)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.to_string()), + } + } + fn set(&self, id: &str, secret: &str) -> Result<(), String> { + Self::entry(id)?.set_password(secret).map_err(|e| e.to_string()) + } + fn delete(&self, id: &str) -> Result<(), String> { + match Self::entry(id)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.to_string()), + } + } +} + /// One GET to GitHub: a 200 answer, or why not (rate limits included). fn http_call(url: &str, timeout: std::time::Duration) -> Result, solidcraft_ui_egui::update::FetchError> { use solidcraft_ui_egui::update::FetchError; @@ -503,6 +572,10 @@ fn main() -> eframe::Result { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); } + // The agent chat's MCP bridge reaches the app through an endpoint of its own, started + // on a free port the first time the chat connects (docs/agent-chat.md). + let ctx = cc.egui_ctx.clone(); + app.agent_chat.control_starter = Some(Box::new(move |tx| control_server::listen_agent(tx, ctx.clone()))); if sample { if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) { app.set_status(format!("sample: {e}"), true); diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 1d05483e..4f887905 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -271,6 +271,13 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink); pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink); } + // Agent chat: a speech bubble with three dots. + "agent_chat" => { + pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink); + for x in [8.5, 12.0, 15.5] { + pen.dot(x, 10.5, ink); + } + } // Help: a question mark in a ring. "help" => { pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); diff --git a/assets/locales/de.json b/assets/locales/de.json index fd416b1f..8e3653bd 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -41,6 +41,147 @@ "ui.active_component": "Aktive Komponente", "ui.agent_cursor": "Agent-Cursor", "ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit", + "ui.agent_chat": "Agenten-Chat", + "ui.agent_chat_command": "Agentenbefehl", + "ui.agent_chat_desktop_only": "Der Agenten-Chat braucht die Desktop-App.", + "ui.agent_chat_side": "Seite des Agenten-Chats", + "ui.agent_chat_left": "Links", + "ui.agent_chat_right": "Rechts", + "ui.agent_chat_turn_limit": "Änderungen pro Nachricht", + "ui.agent_chat_privacy_note": "SolidCraft sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. SolidCraft sieht und speichert nie einen Schlüssel oder ein Passwort.", + "ui.agent_chat_connect": "Verbinden", + "ui.agent_chat_stop": "Beenden", + "ui.agent_chat_stop_tip": "Den Agenten und alles, was er gestartet hat, beenden", + "ui.agent_chat_close": "Schließen", + "ui.agent_chat_minimize": "Zu einer Leiste minimieren", + "ui.agent_chat_dock_left": "Links andocken", + "ui.agent_chat_dock_right": "Rechts andocken", + "ui.agent_chat_send": "Senden", + "ui.agent_chat_cancel": "Abbrechen", + "ui.agent_chat_refresh": "Aktualisieren", + "ui.agent_chat_pick": "Agent", + "ui.agent_chat_custom_hint": "Jedes Programm, das das Agent Client Protocol über stdin und stdout spricht. Es läuft ohne Shell.", + "ui.agent_chat_not_installed": "Nicht installiert", + "ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:", + "ui.agent_chat_detecting": "Suche nach Agenten…", + "ui.agent_chat_signed_in": "angemeldet", + "ui.agent_chat_key_in_env": "Schlüssel gesetzt", + "ui.agent_chat_not_signed_in": "nicht angemeldet", + "ui.agent_chat_state_off": "Nicht verbunden", + "ui.agent_chat_state_starting": "Startet…", + "ui.agent_chat_state_ready": "Bereit", + "ui.agent_chat_state_busy": "Arbeitet…", + "ui.agent_chat_state_auth": "Anmeldung nötig", + "ui.agent_chat_state_exited": "Agent beendet", + "ui.agent_chat_changes_of": "{n}/{limit} Änderungen", + "ui.agent_chat_auth_title": "Beim Agenten anmelden", + "ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; SolidCraft sieht Ihr Passwort oder Ihre Schlüssel nie.", + "ui.agent_chat_auth_none": "Der Agent bietet hier keine Anmeldung an: Melden Sie sich so an, wie es seine Dokumentation beschreibt.", + "ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:", + "ui.agent_chat_auth_agent": "Mit {name} anmelden", + "ui.agent_chat_auth_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie SolidCraft neu.", + "ui.agent_chat_auth_retry": "Ich bin angemeldet: erneut verbinden", + "ui.agent_chat_copy": "Kopieren", + "ui.agent_chat_permission": "Der Agent möchte ausführen: {title}", + "ui.agent_chat_answered": "Antwort: {option}", + "ui.agent_chat_approval": "Darf der Agent das tun?", + "ui.agent_chat_allow": "Erlauben", + "ui.agent_chat_deny": "Ablehnen", + "ui.agent_chat_allowed": "Erlaubt", + "ui.agent_chat_denied": "Abgelehnt", + "ui.agent_chat_changes": "Änderungen: {n}, ein Rückgängig-Schritt", + "ui.agent_chat_undo_changes": "Diese Änderungen rückgängig machen", + "ui.agent_chat_undone": "(rückgängig gemacht)", + "ui.agent_chat_undo_label": "Agenten-Chat: {message}", + "ui.agent_chat_limit": "Der Agent hat die Grenze von {limit} Änderungen für eine Nachricht erreicht. Senden Sie eine weitere Nachricht, damit er weitermacht.", + "ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}", + "ui.agent_chat_wait_turn": "Der Agent arbeitet noch an der letzten Nachricht: Warten Sie oder klicken Sie auf Abbrechen.", + "ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…", + "ui.agent_chat_attach_view": "Ansicht anhängen", + "ui.agent_chat_attach_view_tip": "Ein Bild der aktuellen Ansicht mit der Nachricht senden", + "ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an", + "ui.agent_chat_view_attached": "Ansicht angehängt", + "ui.agent_chat_no_cli": "solidcraft-cli wurde neben der App nicht gefunden, daher kann der Agent das Design nicht erreichen.", + "ui.agent_chat_no_control": "Der Endpunkt des Agenten-Chats ist nicht gestartet, daher kann der Agent das Design nicht erreichen.", + "ui.agent_chat_exited": "Der Agent wurde beendet.", + "ui.agent_chat_failed_start": "Der Agent konnte nicht starten: {error}", + "ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.", + "ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.", + "ui.agent_chat_none_found_short": "Kein Agent gefunden", + "ui.ai_add": "Hinzufügen", + "ui.ai_add_agent": "Agent hinzufügen…", + "ui.ai_add_agent_title": "Agent hinzufügen", + "ui.ai_add_api": "OpenAI-kompatible API", + "ui.ai_add_custom": "Eigener Befehl", + "ui.ai_added": "hinzugefügt", + "ui.ai_add_installed": "Installierter Agent", + "ui.ai_add_installed_note": "Ein eigener Eintrag für einen installierten Agenten, zum Beispiel mit anderen Argumenten. Installierte Agenten erscheinen von selbst.", + "ui.ai_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von SolidCraft folgen den Berechtigungen unter Einstellungen > KI.", + "ui.ai_agents": "Agenten", + "ui.ai_always_allow": "Immer erlauben", + "ui.ai_always_allowed": "Immer erlaubt", + "ui.ai_always_allowed_none": "Noch nichts ist immer erlaubt. Antworten „Immer erlauben“ im Chat erscheinen hier.", + "ui.ai_always_allow_tip": "{tool} für diesen Agenten ab jetzt ohne Nachfrage erlauben", + "ui.ai_auto_allowed": "Automatisch erlaubt: {title} ({why})", + "ui.ai_auto_mode": "Ihre Berechtigungseinstellung", + "ui.ai_auto_ours": "SolidCraft prüft seine eigenen Werkzeuge selbst", + "ui.ai_auto_remembered": "Sie haben „Immer erlauben“ gewählt", + "ui.ai_bad_url": "Geben Sie eine URL ein, die mit http:// oder https:// beginnt", + "ui.ai_base_url": "Basis-URL", + "ui.ai_button": "KI", + "ui.ai_copy_suffix": "Kopie", + "ui.ai_custom_url": "Eigene URL", + "ui.ai_default": "Standard", + "ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später", + "ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI", + "ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen", + "ui.ai_key": "API-Schlüssel", + "ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}", + "ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert", + "ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.", + "ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.", + "ui.ai_mode_all": "Alles erlauben", + "ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.", + "ui.ai_mode_all_warning": "Der Agent kann Ihr Design ohne Nachfrage speichern, überschreiben, exportieren und ersetzen. Nutzen Sie das nur mit einem Agenten, dem Sie vertrauen, und mit gespeicherten Designs.", + "ui.ai_mode_ask": "Jedes Mal fragen", + "ui.ai_mode_ask_note": "Jede Änderung am Design und jede Dateiaktion wartet auf Ihr Erlauben.", + "ui.ai_mode_edits": "Modelländerungen automatisch erlauben", + "ui.ai_mode_edits_note": "Modellierbefehle laufen ohne Nachfrage; Speichern, Öffnen, Exportieren und andere Datei- oder destruktive Aktionen fragen weiterhin.", + "ui.ai_mode_global": "Wie oben eingestellt", + "ui.ai_model": "Modell", + "ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von SolidCraft. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.", + "ui.ai_name": "Name", + "ui.ai_need_command": "Geben Sie den Befehl ein, der den Agenten startet", + "ui.ai_need_model": "Geben Sie den Modellnamen ein", + "ui.ai_no_answer": "Der Agent hat nicht rechtzeitig geantwortet", + "ui.ai_no_http": "Dieser Build kann keine API-Endpunkte erreichen", + "ui.ai_no_keychain": "Der Schlüsselbund des Systems ist nicht verfügbar, daher kann kein Schlüssel gespeichert werden. Ein lokaler Server ohne Schlüssel funktioniert trotzdem.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Berechtigungen", + "ui.ai_preset": "Vorlage", + "ui.ai_remove": "Entfernen", + "ui.ai_request_limit": "nach 24 Anfragen für eine Nachricht angehalten; senden Sie eine weitere Nachricht, um fortzufahren", + "ui.ai_reset": "Zurücksetzen", + "ui.ai_settings": "KI-Einstellungen…", + "ui.ai_show_features": "KI-Funktionen anzeigen", + "ui.ai_show_features_note": "Der Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.", + "ui.ai_switch_tip": "Den Agenten für dieses Gespräch wechseln", + "ui.ai_test": "Testen", + "ui.ai_test_acp_ok": "Antwortet: {agent}", + "ui.ai_test_acp_tip": "Startet den Agenten kurz und bittet um eine Sitzung; beendet ihn wieder", + "ui.ai_test_api_tip": "Fragt den Endpunkt nach seinen Modellen", + "ui.ai_testing": "Teste…", + "ui.ai_test_no_model": "Erreicht: {count} Modelle, aber nicht {model}", + "ui.ai_test_ok": "Erreicht: {count} Modelle", + "ui.ai_toolbar_button": "Schaltfläche in der Werkzeugleiste anzeigen", + "ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.", + "ui.ai_tool_done": "fertig", + "ui.ai_tool_failed": "fehlgeschlagen", + "ui.ai_tool_running": "läuft", + "ui.ai_tool_unfold": "Klicken, um das Ergebnis zu zeigen", + "ui.ai_tool_waiting": "wartet", + "ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen", + "AI": "KI", "ui.all": "Alle", "ui.analysis": "Analyse", "ui.angle": "Winkel", diff --git a/assets/locales/en.json b/assets/locales/en.json index 84f2bcdc..3b905524 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -323,6 +323,147 @@ "ui.active_component": "Active component", "ui.agent_cursor": "Agent cursor", "ui.agent_cursor_speed": "Agent cursor speed", + "ui.agent_chat": "Agent chat", + "ui.agent_chat_command": "Agent command", + "ui.agent_chat_desktop_only": "The agent chat needs the desktop app.", + "ui.agent_chat_side": "Agent chat side", + "ui.agent_chat_left": "Left", + "ui.agent_chat_right": "Right", + "ui.agent_chat_turn_limit": "Changes per message", + "ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.", + "ui.agent_chat_connect": "Connect", + "ui.agent_chat_stop": "Stop", + "ui.agent_chat_stop_tip": "Stop the agent and everything it started", + "ui.agent_chat_close": "Close", + "ui.agent_chat_minimize": "Minimise to a strip", + "ui.agent_chat_dock_left": "Dock on the left", + "ui.agent_chat_dock_right": "Dock on the right", + "ui.agent_chat_send": "Send", + "ui.agent_chat_cancel": "Cancel", + "ui.agent_chat_refresh": "Refresh", + "ui.agent_chat_pick": "Agent", + "ui.agent_chat_custom_hint": "Any program that speaks the Agent Client Protocol on stdin and stdout. No shell runs it.", + "ui.agent_chat_not_installed": "Not installed", + "ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:", + "ui.agent_chat_detecting": "Looking for agents…", + "ui.agent_chat_signed_in": "signed in", + "ui.agent_chat_key_in_env": "key set", + "ui.agent_chat_not_signed_in": "not signed in", + "ui.agent_chat_state_off": "Not connected", + "ui.agent_chat_state_starting": "Starting…", + "ui.agent_chat_state_ready": "Ready", + "ui.agent_chat_state_busy": "Working…", + "ui.agent_chat_state_auth": "Sign-in needed", + "ui.agent_chat_state_exited": "Agent stopped", + "ui.agent_chat_changes_of": "{n}/{limit} changes", + "ui.agent_chat_auth_title": "Sign in to the agent", + "ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.", + "ui.agent_chat_auth_none": "The agent offers no sign-in method here: sign in the way its own documentation describes.", + "ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:", + "ui.agent_chat_auth_agent": "Sign in with {name}", + "ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.", + "ui.agent_chat_auth_retry": "I've signed in: connect again", + "ui.agent_chat_copy": "Copy", + "ui.agent_chat_permission": "The agent asks to run: {title}", + "ui.agent_chat_answered": "Answer: {option}", + "ui.agent_chat_approval": "Allow the agent to do this?", + "ui.agent_chat_allow": "Allow", + "ui.agent_chat_deny": "Deny", + "ui.agent_chat_allowed": "Allowed", + "ui.agent_chat_denied": "Denied", + "ui.agent_chat_changes": "Changes: {n}, one undo step", + "ui.agent_chat_undo_changes": "Undo these changes", + "ui.agent_chat_undone": "(undone)", + "ui.agent_chat_undo_label": "Agent chat: {message}", + "ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.", + "ui.agent_chat_refused": "Not allowed for the agent: {reason}", + "ui.agent_chat_wait_turn": "The agent is still working on the last message: wait for it or press Cancel.", + "ui.agent_chat_placeholder": "Ask the agent to change the design…", + "ui.agent_chat_attach_view": "Attach view", + "ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message", + "ui.agent_chat_no_images": "This agent doesn't take pictures", + "ui.agent_chat_view_attached": "View attached", + "ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.", + "ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.", + "ui.agent_chat_exited": "The agent stopped.", + "ui.agent_chat_failed_start": "The agent could not start: {error}", + "ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.", + "ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.", + "ui.agent_chat_none_found_short": "No agent found", + "ui.ai_add": "Add", + "ui.ai_add_agent": "Add agent…", + "ui.ai_add_agent_title": "Add agent", + "ui.ai_add_api": "OpenAI-compatible API", + "ui.ai_add_custom": "Custom command", + "ui.ai_added": "added", + "ui.ai_add_installed": "Installed agent", + "ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.", + "ui.ai_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. SolidCraft's tools follow the permissions in Preferences > AI.", + "ui.ai_agents": "Agents", + "ui.ai_always_allow": "Always allow", + "ui.ai_always_allowed": "Always allowed", + "ui.ai_always_allowed_none": "Nothing is always allowed yet. \"Always allow\" answers you give in the chat are listed here.", + "ui.ai_always_allow_tip": "Allow {tool} for this agent from now on, without asking", + "ui.ai_auto_allowed": "Auto-allowed: {title} ({why})", + "ui.ai_auto_mode": "your permission setting", + "ui.ai_auto_ours": "SolidCraft checks its own tools itself", + "ui.ai_auto_remembered": "you chose Always allow", + "ui.ai_bad_url": "Enter a URL that starts with http:// or https://", + "ui.ai_base_url": "Base URL", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "copy", + "ui.ai_custom_url": "Custom URL", + "ui.ai_default": "Default", + "ui.ai_http_busy": "the endpoint is busy or over its limit; try again later", + "ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI", + "ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name", + "ui.ai_key": "API key", + "ui.ai_keychain_error": "The system keychain refused: {error}", + "ui.ai_key_hint": "optional; kept in the system keychain", + "ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.", + "ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.", + "ui.ai_mode_all": "Allow everything", + "ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.", + "ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.", + "ui.ai_mode_ask": "Ask every time", + "ui.ai_mode_ask_note": "Every change to the design, and every file action, waits for your Allow.", + "ui.ai_mode_edits": "Auto-allow model edits", + "ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.", + "ui.ai_mode_global": "As set above", + "ui.ai_model": "Model", + "ui.ai_modes_scope": "These settings cover SolidCraft's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.", + "ui.ai_name": "Name", + "ui.ai_need_command": "Enter the command that starts the agent", + "ui.ai_need_model": "Enter the model name", + "ui.ai_no_answer": "The agent did not answer in time", + "ui.ai_no_http": "This build can't reach API endpoints", + "ui.ai_no_keychain": "The system keychain isn't available, so a key can't be stored. A local server without a key still works.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Permissions", + "ui.ai_preset": "Preset", + "ui.ai_remove": "Remove", + "ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on", + "ui.ai_reset": "Reset", + "ui.ai_settings": "AI settings…", + "ui.ai_show_features": "Show AI features", + "ui.ai_show_features_note": "The agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.", + "ui.ai_switch_tip": "Change the agent for this conversation", + "ui.ai_test": "Test", + "ui.ai_test_acp_ok": "Answers: {agent}", + "ui.ai_test_acp_tip": "Starts the agent briefly and asks it for a session; ends it again", + "ui.ai_test_api_tip": "Asks the endpoint for its models", + "ui.ai_testing": "Testing…", + "ui.ai_test_no_model": "Reached: {count} models, but not {model}", + "ui.ai_test_ok": "Reached: {count} models", + "ui.ai_toolbar_button": "Show button in toolbar", + "ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.", + "ui.ai_tool_done": "done", + "ui.ai_tool_failed": "failed", + "ui.ai_tool_running": "running", + "ui.ai_tool_unfold": "Click to show what it returned", + "ui.ai_tool_waiting": "waiting", + "ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step", + "AI": "AI", "ui.all": "All", "ui.analysis": "Analysis", "ui.angle": "Angle", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index ec75bb8d..0a93e692 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -323,6 +323,147 @@ "ui.active_component": "アクティブなコンポーネント", "ui.agent_cursor": "エージェントカーソル", "ui.agent_cursor_speed": "エージェントカーソル速度", + "ui.agent_chat": "エージェントチャット", + "ui.agent_chat_command": "エージェントのコマンド", + "ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。", + "ui.agent_chat_side": "エージェントチャットの位置", + "ui.agent_chat_left": "左", + "ui.agent_chat_right": "右", + "ui.agent_chat_turn_limit": "1メッセージあたりの変更数", + "ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。", + "ui.agent_chat_connect": "接続", + "ui.agent_chat_stop": "停止", + "ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します", + "ui.agent_chat_close": "閉じる", + "ui.agent_chat_minimize": "細いバーに最小化", + "ui.agent_chat_dock_left": "左にドッキング", + "ui.agent_chat_dock_right": "右にドッキング", + "ui.agent_chat_send": "送信", + "ui.agent_chat_cancel": "キャンセル", + "ui.agent_chat_refresh": "再検索", + "ui.agent_chat_pick": "エージェント", + "ui.agent_chat_custom_hint": "標準入出力で Agent Client Protocol を話す任意のプログラム。シェルは使いません。", + "ui.agent_chat_not_installed": "未インストール", + "ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:", + "ui.agent_chat_detecting": "エージェントを検索中…", + "ui.agent_chat_signed_in": "サインイン済み", + "ui.agent_chat_key_in_env": "キー設定済み", + "ui.agent_chat_not_signed_in": "未サインイン", + "ui.agent_chat_state_off": "未接続", + "ui.agent_chat_state_starting": "起動中…", + "ui.agent_chat_state_ready": "準備完了", + "ui.agent_chat_state_busy": "作業中…", + "ui.agent_chat_state_auth": "サインインが必要", + "ui.agent_chat_state_exited": "エージェント停止", + "ui.agent_chat_changes_of": "変更 {n}/{limit}", + "ui.agent_chat_auth_title": "エージェントにサインイン", + "ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。", + "ui.agent_chat_auth_none": "このエージェントはここでのサインイン方法を提示していません。エージェントのドキュメントに従ってサインインしてください。", + "ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:", + "ui.agent_chat_auth_agent": "{name} でサインイン", + "ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。", + "ui.agent_chat_auth_retry": "サインインしました: 再接続", + "ui.agent_chat_copy": "コピー", + "ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}", + "ui.agent_chat_answered": "回答: {option}", + "ui.agent_chat_approval": "エージェントにこの操作を許可しますか?", + "ui.agent_chat_allow": "許可", + "ui.agent_chat_deny": "拒否", + "ui.agent_chat_allowed": "許可しました", + "ui.agent_chat_denied": "拒否しました", + "ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)", + "ui.agent_chat_undo_changes": "これらの変更を元に戻す", + "ui.agent_chat_undone": "(元に戻しました)", + "ui.agent_chat_undo_label": "エージェントチャット: {message}", + "ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。", + "ui.agent_chat_refused": "エージェントには許可されていません: {reason}", + "ui.agent_chat_wait_turn": "エージェントはまだ前のメッセージを処理中です。待つか「キャンセル」を押してください。", + "ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…", + "ui.agent_chat_attach_view": "ビューを添付", + "ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります", + "ui.agent_chat_no_images": "このエージェントは画像を受け付けません", + "ui.agent_chat_view_attached": "ビューを添付済み", + "ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。", + "ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。", + "ui.agent_chat_exited": "エージェントが停止しました。", + "ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}", + "ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。", + "ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。", + "ui.agent_chat_none_found_short": "エージェントが見つかりません", + "ui.ai_add": "追加", + "ui.ai_add_agent": "エージェントを追加…", + "ui.ai_add_agent_title": "エージェントを追加", + "ui.ai_add_api": "OpenAI 互換 API", + "ui.ai_add_custom": "カスタムコマンド", + "ui.ai_added": "追加済み", + "ui.ai_add_installed": "インストール済みエージェント", + "ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。", + "ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。SolidCraft のツールは「環境設定 > AI」の権限に従います。", + "ui.ai_agents": "エージェント", + "ui.ai_always_allow": "常に許可", + "ui.ai_always_allowed": "常に許可されたもの", + "ui.ai_always_allowed_none": "まだ常に許可されたものはありません。チャットで「常に許可」と答えたものがここに表示されます。", + "ui.ai_always_allow_tip": "今後このエージェントに {tool} を確認なしで許可します", + "ui.ai_auto_allowed": "自動許可: {title}({why})", + "ui.ai_auto_mode": "権限の設定", + "ui.ai_auto_ours": "SolidCraft のツールは SolidCraft 自身が確認します", + "ui.ai_auto_remembered": "「常に許可」を選択済み", + "ui.ai_bad_url": "http:// または https:// で始まる URL を入力してください", + "ui.ai_base_url": "ベース URL", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "コピー", + "ui.ai_custom_url": "カスタム URL", + "ui.ai_default": "既定", + "ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください", + "ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください", + "ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください", + "ui.ai_key": "API キー", + "ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}", + "ui.ai_key_hint": "任意。システムのキーチェーンに保存", + "ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。", + "ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。", + "ui.ai_mode_all": "すべて許可", + "ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。", + "ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。", + "ui.ai_mode_ask": "毎回確認", + "ui.ai_mode_ask_note": "デザインへのすべての変更とファイル操作が、あなたの許可を待ちます。", + "ui.ai_mode_edits": "モデル編集を自動許可", + "ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。", + "ui.ai_mode_global": "上の設定に従う", + "ui.ai_model": "モデル", + "ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。", + "ui.ai_name": "名前", + "ui.ai_need_command": "エージェントを起動するコマンドを入力してください", + "ui.ai_need_model": "モデル名を入力してください", + "ui.ai_no_answer": "エージェントが時間内に応答しませんでした", + "ui.ai_no_http": "このビルドは API エンドポイントに接続できません", + "ui.ai_no_keychain": "システムのキーチェーンが使えないため、キーを保存できません。キー不要のローカルサーバーは利用できます。", + "ui.ai_panel": "パネル", + "ui.ai_permissions": "権限", + "ui.ai_preset": "プリセット", + "ui.ai_remove": "削除", + "ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください", + "ui.ai_reset": "リセット", + "ui.ai_settings": "AI 設定…", + "ui.ai_show_features": "AI 機能を表示", + "ui.ai_show_features_note": "エージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。", + "ui.ai_switch_tip": "この会話のエージェントを切り替え", + "ui.ai_test": "テスト", + "ui.ai_test_acp_ok": "応答あり: {agent}", + "ui.ai_test_acp_tip": "エージェントを短時間起動してセッションを要求し、終了します", + "ui.ai_test_api_tip": "エンドポイントにモデル一覧を問い合わせます", + "ui.ai_testing": "テスト中…", + "ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)", + "ui.ai_test_ok": "接続成功: {count} 個のモデル", + "ui.ai_toolbar_button": "ツールバーにボタンを表示", + "ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。", + "ui.ai_tool_done": "完了", + "ui.ai_tool_failed": "失敗", + "ui.ai_tool_running": "実行中", + "ui.ai_tool_unfold": "クリックして結果を表示", + "ui.ai_tool_waiting": "待機中", + "ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します", + "AI": "AI", "ui.all": "すべて", "ui.analysis": "解析", "ui.angle": "角度", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 3472c7c7..b600071b 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -41,6 +41,147 @@ "ui.active_component": "활성 컴포넌트", "ui.agent_cursor": "에이전트 커서", "ui.agent_cursor_speed": "에이전트 커서 속도", + "ui.agent_chat": "에이전트 채팅", + "ui.agent_chat_command": "에이전트 명령", + "ui.agent_chat_desktop_only": "에이전트 채팅은 데스크톱 앱이 필요합니다.", + "ui.agent_chat_side": "에이전트 채팅 위치", + "ui.agent_chat_left": "왼쪽", + "ui.agent_chat_right": "오른쪽", + "ui.agent_chat_turn_limit": "메시지당 변경 수", + "ui.agent_chat_privacy_note": "SolidCraft는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. SolidCraft는 키나 비밀번호를 보거나 저장하지 않습니다.", + "ui.agent_chat_connect": "연결", + "ui.agent_chat_stop": "중지", + "ui.agent_chat_stop_tip": "에이전트와 에이전트가 시작한 모든 것을 중지", + "ui.agent_chat_close": "닫기", + "ui.agent_chat_minimize": "얇은 막대로 최소화", + "ui.agent_chat_dock_left": "왼쪽에 고정", + "ui.agent_chat_dock_right": "오른쪽에 고정", + "ui.agent_chat_send": "보내기", + "ui.agent_chat_cancel": "취소", + "ui.agent_chat_refresh": "새로 고침", + "ui.agent_chat_pick": "에이전트", + "ui.agent_chat_custom_hint": "표준 입출력으로 Agent Client Protocol을 사용하는 모든 프로그램. 셸 없이 실행됩니다.", + "ui.agent_chat_not_installed": "설치되지 않음", + "ui.agent_chat_none_found": "코딩 에이전트를 찾지 못했습니다. 다음 중 하나를 설치한 뒤 새로 고침을 누르세요:", + "ui.agent_chat_detecting": "에이전트 찾는 중…", + "ui.agent_chat_signed_in": "로그인됨", + "ui.agent_chat_key_in_env": "키 설정됨", + "ui.agent_chat_not_signed_in": "로그인 안 됨", + "ui.agent_chat_state_off": "연결 안 됨", + "ui.agent_chat_state_starting": "시작하는 중…", + "ui.agent_chat_state_ready": "준비됨", + "ui.agent_chat_state_busy": "작업 중…", + "ui.agent_chat_state_auth": "로그인 필요", + "ui.agent_chat_state_exited": "에이전트 중지됨", + "ui.agent_chat_changes_of": "변경 {n}/{limit}", + "ui.agent_chat_auth_title": "에이전트에 로그인", + "ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, SolidCraft는 비밀번호나 키를 보지 않습니다.", + "ui.agent_chat_auth_none": "이 에이전트는 여기서 로그인 방법을 제공하지 않습니다. 에이전트 문서에 안내된 방법으로 로그인하세요.", + "ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:", + "ui.agent_chat_auth_agent": "{name}(으)로 로그인", + "ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 SolidCraft를 다시 시작하세요.", + "ui.agent_chat_auth_retry": "로그인했습니다: 다시 연결", + "ui.agent_chat_copy": "복사", + "ui.agent_chat_permission": "에이전트가 실행 허락을 요청합니다: {title}", + "ui.agent_chat_answered": "응답: {option}", + "ui.agent_chat_approval": "에이전트가 이 작업을 하도록 허용할까요?", + "ui.agent_chat_allow": "허용", + "ui.agent_chat_deny": "거부", + "ui.agent_chat_allowed": "허용함", + "ui.agent_chat_denied": "거부함", + "ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번", + "ui.agent_chat_undo_changes": "이 변경 사항 실행 취소", + "ui.agent_chat_undone": "(취소됨)", + "ui.agent_chat_undo_label": "에이전트 채팅: {message}", + "ui.agent_chat_limit": "에이전트가 메시지당 변경 한도 {limit}에 도달했습니다. 계속하게 하려면 메시지를 하나 더 보내세요.", + "ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}", + "ui.agent_chat_wait_turn": "에이전트가 아직 이전 메시지를 처리 중입니다. 기다리거나 취소를 누르세요.", + "ui.agent_chat_placeholder": "에이전트에게 디자인 변경을 요청하세요…", + "ui.agent_chat_attach_view": "뷰 첨부", + "ui.agent_chat_attach_view_tip": "현재 뷰의 이미지를 메시지와 함께 보냅니다", + "ui.agent_chat_no_images": "이 에이전트는 이미지를 받지 않습니다", + "ui.agent_chat_view_attached": "뷰 첨부됨", + "ui.agent_chat_no_cli": "앱 옆에서 solidcraft-cli를 찾지 못해 에이전트가 디자인에 접근할 수 없습니다.", + "ui.agent_chat_no_control": "에이전트 채팅 엔드포인트가 시작되지 않아 에이전트가 디자인에 접근할 수 없습니다.", + "ui.agent_chat_exited": "에이전트가 중지되었습니다.", + "ui.agent_chat_failed_start": "에이전트를 시작할 수 없습니다: {error}", + "ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.", + "ui.agent_chat_privacy": "사용자의 계정으로 실행하는 에이전트 외에는 아무 데도 전송되지 않습니다.", + "ui.agent_chat_none_found_short": "에이전트를 찾지 못함", + "ui.ai_add": "추가", + "ui.ai_add_agent": "에이전트 추가…", + "ui.ai_add_agent_title": "에이전트 추가", + "ui.ai_add_api": "OpenAI 호환 API", + "ui.ai_add_custom": "사용자 지정 명령", + "ui.ai_added": "추가됨", + "ui.ai_add_installed": "설치된 에이전트", + "ui.ai_add_installed_note": "설치된 에이전트에 대한 별도 항목(예: 다른 인수로 실행). 설치된 에이전트는 자동으로 표시됩니다.", + "ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. SolidCraft 도구는 환경 설정 > AI의 권한을 따릅니다.", + "ui.ai_agents": "에이전트", + "ui.ai_always_allow": "항상 허용", + "ui.ai_always_allowed": "항상 허용됨", + "ui.ai_always_allowed_none": "아직 항상 허용된 것이 없습니다. 채팅에서 \"항상 허용\"으로 답한 항목이 여기에 표시됩니다.", + "ui.ai_always_allow_tip": "이 에이전트에게 앞으로 {tool}을(를) 묻지 않고 허용", + "ui.ai_auto_allowed": "자동 허용: {title} ({why})", + "ui.ai_auto_mode": "권한 설정", + "ui.ai_auto_ours": "SolidCraft가 자체 도구를 직접 확인합니다", + "ui.ai_auto_remembered": "\"항상 허용\"을 선택함", + "ui.ai_bad_url": "http:// 또는 https://로 시작하는 URL을 입력하세요", + "ui.ai_base_url": "기본 URL", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "복사본", + "ui.ai_custom_url": "사용자 지정 URL", + "ui.ai_default": "기본값", + "ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요", + "ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요", + "ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요", + "ui.ai_key": "API 키", + "ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}", + "ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장", + "ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.", + "ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.", + "ui.ai_mode_all": "모두 허용", + "ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.", + "ui.ai_mode_all_warning": "에이전트가 묻지 않고 디자인을 저장, 덮어쓰기, 내보내기, 교체할 수 있습니다. 신뢰하는 에이전트와 저장된 디자인에서만 사용하세요.", + "ui.ai_mode_ask": "매번 묻기", + "ui.ai_mode_ask_note": "디자인의 모든 변경과 파일 작업이 사용자의 허용을 기다립니다.", + "ui.ai_mode_edits": "모델 편집 자동 허용", + "ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.", + "ui.ai_mode_global": "위 설정 따름", + "ui.ai_model": "모델", + "ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.", + "ui.ai_name": "이름", + "ui.ai_need_command": "에이전트를 시작하는 명령을 입력하세요", + "ui.ai_need_model": "모델 이름을 입력하세요", + "ui.ai_no_answer": "에이전트가 제시간에 응답하지 않았습니다", + "ui.ai_no_http": "이 빌드는 API 엔드포인트에 연결할 수 없습니다", + "ui.ai_no_keychain": "시스템 키체인을 사용할 수 없어 키를 저장할 수 없습니다. 키가 필요 없는 로컬 서버는 사용할 수 있습니다.", + "ui.ai_panel": "패널", + "ui.ai_permissions": "권한", + "ui.ai_preset": "프리셋", + "ui.ai_remove": "제거", + "ui.ai_request_limit": "메시지 하나에 24번 요청 후 멈췄습니다. 계속하려면 메시지를 하나 더 보내세요", + "ui.ai_reset": "재설정", + "ui.ai_settings": "AI 설정…", + "ui.ai_show_features": "AI 기능 표시", + "ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.", + "ui.ai_switch_tip": "이 대화의 에이전트 변경", + "ui.ai_test": "테스트", + "ui.ai_test_acp_ok": "응답: {agent}", + "ui.ai_test_acp_tip": "에이전트를 잠시 시작해 세션을 요청한 뒤 종료합니다", + "ui.ai_test_api_tip": "엔드포인트에 모델 목록을 요청합니다", + "ui.ai_testing": "테스트 중…", + "ui.ai_test_no_model": "연결됨: 모델 {count}개, {model} 없음", + "ui.ai_test_ok": "연결됨: 모델 {count}개", + "ui.ai_toolbar_button": "도구 모음에 버튼 표시", + "ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.", + "ui.ai_tool_done": "완료", + "ui.ai_tool_failed": "실패", + "ui.ai_tool_running": "실행 중", + "ui.ai_tool_unfold": "클릭하면 결과를 표시", + "ui.ai_tool_waiting": "대기 중", + "ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소", + "AI": "AI", "ui.all": "모두", "ui.analysis": "분석", "ui.angle": "각도", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index d5d626c0..29ef2729 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -323,6 +323,147 @@ "ui.active_component": "活动组件", "ui.agent_cursor": "代理光标", "ui.agent_cursor_speed": "代理光标速度", + "ui.agent_chat": "智能体聊天", + "ui.agent_chat_command": "智能体命令", + "ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。", + "ui.agent_chat_side": "智能体聊天位置", + "ui.agent_chat_left": "左侧", + "ui.agent_chat_right": "右侧", + "ui.agent_chat_turn_limit": "每条消息的更改数", + "ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。", + "ui.agent_chat_connect": "连接", + "ui.agent_chat_stop": "停止", + "ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程", + "ui.agent_chat_close": "关闭", + "ui.agent_chat_minimize": "最小化为细条", + "ui.agent_chat_dock_left": "停靠在左侧", + "ui.agent_chat_dock_right": "停靠在右侧", + "ui.agent_chat_send": "发送", + "ui.agent_chat_cancel": "取消", + "ui.agent_chat_refresh": "刷新", + "ui.agent_chat_pick": "智能体", + "ui.agent_chat_custom_hint": "任何通过标准输入输出使用 Agent Client Protocol 的程序。不经过 shell 运行。", + "ui.agent_chat_not_installed": "未安装", + "ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:", + "ui.agent_chat_detecting": "正在查找智能体…", + "ui.agent_chat_signed_in": "已登录", + "ui.agent_chat_key_in_env": "已设置密钥", + "ui.agent_chat_not_signed_in": "未登录", + "ui.agent_chat_state_off": "未连接", + "ui.agent_chat_state_starting": "正在启动…", + "ui.agent_chat_state_ready": "就绪", + "ui.agent_chat_state_busy": "工作中…", + "ui.agent_chat_state_auth": "需要登录", + "ui.agent_chat_state_exited": "智能体已停止", + "ui.agent_chat_changes_of": "更改 {n}/{limit}", + "ui.agent_chat_auth_title": "登录智能体", + "ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。", + "ui.agent_chat_auth_none": "该智能体未在此提供登录方式:请按照它自己的文档登录。", + "ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:", + "ui.agent_chat_auth_agent": "使用 {name} 登录", + "ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。", + "ui.agent_chat_auth_retry": "我已登录:重新连接", + "ui.agent_chat_copy": "复制", + "ui.agent_chat_permission": "智能体请求运行:{title}", + "ui.agent_chat_answered": "回答:{option}", + "ui.agent_chat_approval": "允许智能体执行此操作吗?", + "ui.agent_chat_allow": "允许", + "ui.agent_chat_deny": "拒绝", + "ui.agent_chat_allowed": "已允许", + "ui.agent_chat_denied": "已拒绝", + "ui.agent_chat_changes": "{n} 项更改,一步撤销", + "ui.agent_chat_undo_changes": "撤销这些更改", + "ui.agent_chat_undone": "(已撤销)", + "ui.agent_chat_undo_label": "智能体聊天:{message}", + "ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。", + "ui.agent_chat_refused": "不允许智能体执行:{reason}", + "ui.agent_chat_wait_turn": "智能体仍在处理上一条消息:请等待或点击取消。", + "ui.agent_chat_placeholder": "让智能体修改设计…", + "ui.agent_chat_attach_view": "附加视图", + "ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片", + "ui.agent_chat_no_images": "该智能体不接收图片", + "ui.agent_chat_view_attached": "已附加视图", + "ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。", + "ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。", + "ui.agent_chat_exited": "智能体已停止。", + "ui.agent_chat_failed_start": "智能体无法启动:{error}", + "ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。", + "ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。", + "ui.agent_chat_none_found_short": "未找到智能体", + "ui.ai_add": "添加", + "ui.ai_add_agent": "添加智能体…", + "ui.ai_add_agent_title": "添加智能体", + "ui.ai_add_api": "OpenAI 兼容 API", + "ui.ai_add_custom": "自定义命令", + "ui.ai_added": "已添加", + "ui.ai_add_installed": "已安装的智能体", + "ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。", + "ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。SolidCraft 的工具遵循“首选项 > AI”中的权限。", + "ui.ai_agents": "智能体", + "ui.ai_always_allow": "始终允许", + "ui.ai_always_allowed": "始终允许的项目", + "ui.ai_always_allowed_none": "尚无始终允许的项目。你在聊天中选择“始终允许”的回答会列在这里。", + "ui.ai_always_allow_tip": "从现在起允许该智能体使用 {tool},不再询问", + "ui.ai_auto_allowed": "已自动允许:{title}({why})", + "ui.ai_auto_mode": "你的权限设置", + "ui.ai_auto_ours": "SolidCraft 会自行检查它自己的工具", + "ui.ai_auto_remembered": "你选择了始终允许", + "ui.ai_bad_url": "请输入以 http:// 或 https:// 开头的 URL", + "ui.ai_base_url": "基础 URL", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "副本", + "ui.ai_custom_url": "自定义 URL", + "ui.ai_default": "默认", + "ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试", + "ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查", + "ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称", + "ui.ai_key": "API 密钥", + "ui.ai_keychain_error": "系统钥匙串拒绝:{error}", + "ui.ai_key_hint": "可选;保存在系统钥匙串中", + "ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。", + "ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。", + "ui.ai_mode_all": "全部允许", + "ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。", + "ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。", + "ui.ai_mode_ask": "每次询问", + "ui.ai_mode_ask_note": "对设计的每项更改和每个文件操作都要等待你的允许。", + "ui.ai_mode_edits": "自动允许模型编辑", + "ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。", + "ui.ai_mode_global": "按上方设置", + "ui.ai_model": "模型", + "ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。", + "ui.ai_name": "名称", + "ui.ai_need_command": "请输入启动智能体的命令", + "ui.ai_need_model": "请输入模型名称", + "ui.ai_no_answer": "智能体未及时响应", + "ui.ai_no_http": "此版本无法访问 API 端点", + "ui.ai_no_keychain": "系统钥匙串不可用,无法保存密钥。无需密钥的本地服务器仍可使用。", + "ui.ai_panel": "面板", + "ui.ai_permissions": "权限", + "ui.ai_preset": "预设", + "ui.ai_remove": "移除", + "ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续", + "ui.ai_reset": "重置", + "ui.ai_settings": "AI 设置…", + "ui.ai_show_features": "显示 AI 功能", + "ui.ai_show_features_note": "智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。", + "ui.ai_switch_tip": "更改此对话的智能体", + "ui.ai_test": "测试", + "ui.ai_test_acp_ok": "已响应:{agent}", + "ui.ai_test_acp_tip": "短暂启动智能体并请求一个会话,然后结束它", + "ui.ai_test_api_tip": "向端点查询其模型列表", + "ui.ai_testing": "正在测试…", + "ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}", + "ui.ai_test_ok": "已连接:{count} 个模型", + "ui.ai_toolbar_button": "在工具栏显示按钮", + "ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。", + "ui.ai_tool_done": "完成", + "ui.ai_tool_failed": "失败", + "ui.ai_tool_running": "运行中", + "ui.ai_tool_unfold": "点击显示返回内容", + "ui.ai_tool_waiting": "等待中", + "ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销", + "AI": "AI", "ui.all": "全部", "ui.angle": "角度", "ui.angle_decimals": "角度小数位", diff --git a/crates/ui-egui/examples/acp_test_agent.rs b/crates/ui-egui/examples/acp_test_agent.rs new file mode 100644 index 00000000..f4121319 --- /dev/null +++ b/crates/ui-egui/examples/acp_test_agent.rs @@ -0,0 +1,226 @@ +//! A scripted ACP agent for trying the agent chat without an account (docs/agent-chat.md): +//! it speaks the Agent Client Protocol on stdin and stdout like the real adapters, starts the MCP +//! server the chat gives it (`solidcraft-cli mcp --connect …`) and models through it. No model +//! runs: what it does is fixed. +//! +//! ```text +//! cargo build --release -p solidcraft-ui-egui --example acp_test_agent +//! # Preferences → General → Agent chat → Add custom command: target/release/examples/acp_test_agent +//! ``` +//! +//! A message: a plan, a box made with one `batch`, a fillet, a measurement. A message with "save" +//! in it only saves to the user's Documents folder (which the chat holds for Allow or Deny); +//! with "ask" in it the agent first asks its own permission for the batch. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +fn send(v: &Value) { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{v}"); + let _ = out.flush(); +} + +fn update(session: &str, u: Value) { + send(&json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session, "update": u}})); +} + +fn say(session: &str, text: &str) { + update(session, json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}})); +} + +fn tool(session: &str, id: &str, title: &str, kind: &str, status: &str, output: Option<&str>) { + let mut u = json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "title": title, "kind": kind, "status": status}); + if status == "pending" { + u["sessionUpdate"] = json!("tool_call"); + } + if let Some(o) = output { + u["content"] = json!([{"type": "content", "content": {"type": "text", "text": o}}]); + } + update(session, u); +} + +fn plan(session: &str, done: usize) { + let steps = ["Make a 40 × 30 × 20 mm block", "Round its vertical edges", "Measure it"]; + let entries: Vec = steps + .iter() + .enumerate() + .map(|(i, s)| { + let status = if i < done { + "completed" + } else if i == done { + "in_progress" + } else { + "pending" + }; + json!({"content": s, "priority": "medium", "status": status}) + }) + .collect(); + update(session, json!({"sessionUpdate": "plan", "entries": entries})); +} + +/// The MCP server the chat handed over, as a child speaking JSON-RPC lines. +struct Mcp { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next: u64, +} + +impl Mcp { + fn start(server: &Value) -> Option { + let program = server.get("command")?.as_str()?; + let args: Vec = server.get("args")?.as_array()?.iter().filter_map(|a| a.as_str().map(str::to_string)).collect(); + let mut child = Command::new(program).args(&args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let stdin = child.stdin.take()?; + let stdout = BufReader::new(child.stdout.take()?); + let mut m = Mcp { child, stdin, stdout, next: 0 }; + m.call("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "acp-test-agent", "version": "1"}}))?; + let _ = writeln!(m.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + Some(m) + } + + fn call(&mut self, method: &str, params: Value) -> Option { + self.next += 1; + let id = self.next; + writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).ok()?; + self.stdin.flush().ok()?; + loop { + let mut line = String::new(); + if self.stdout.read_line(&mut line).ok()? == 0 { + return None; + } + let v: Value = serde_json::from_str(line.trim()).ok()?; + if v.get("id").and_then(Value::as_u64) == Some(id) { + return Some(v); + } + } + } + + /// A tool's text and whether it failed. + fn tool(&mut self, name: &str, args: Value) -> (String, bool) { + match self.call("tools/call", json!({"name": name, "arguments": args})) { + Some(r) => { + let text = r.pointer("/result/content/0/text").and_then(Value::as_str).unwrap_or("").to_string(); + let failed = r.pointer("/result/isError").and_then(Value::as_bool).unwrap_or(false) || r.get("error").is_some(); + let text = if text.is_empty() { r.get("error").map(Value::to_string).unwrap_or_default() } else { text }; + (text.chars().take(600).collect(), failed) + } + None => ("the MCP server did not answer".into(), true), + } + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn main() { + let session = "test-session-1"; + let mut mcp: Option = None; + let stdin = std::io::stdin(); + let mut lines = stdin.lock().lines(); + while let Some(Ok(line)) = lines.next() { + let Ok(msg) = serde_json::from_str::(line.trim()) else { continue }; + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let reply = |result: Value| send(&json!({"jsonrpc": "2.0", "id": id, "result": result})); + match msg.get("method").and_then(Value::as_str) { + Some("initialize") => reply(json!({ + "protocolVersion": 1, + "agentInfo": {"name": "acp-test-agent", "title": "SolidCraft test agent", "version": env!("CARGO_PKG_VERSION")}, + "agentCapabilities": {"promptCapabilities": {"image": true}}, + "authMethods": [], + })), + Some("session/new") => { + mcp = msg.pointer("/params/mcpServers/0").and_then(Mcp::start); + reply(json!({"sessionId": session, "modes": {"currentModeId": "default", "availableModes": [ + {"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept edits"}, {"id": "plan", "name": "Plan"}, + ]}})); + } + Some("session/prompt") => { + let text: String = msg + .pointer("/params/prompt") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|b| b.get("text").and_then(Value::as_str)).collect::>().join(" ")) + .unwrap_or_default() + .to_lowercase(); + let images = msg.pointer("/params/prompt").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|b| b["type"] == "image").count()); + let Some(m) = mcp.as_mut() else { + say(session, "I have no SolidCraft tools in this session."); + reply(json!({"stopReason": "end_turn"})); + continue; + }; + // "save": only the save (the design is there already). + if text.contains("save") { + let path = std::env::var_os("HOME").map(std::path::PathBuf::from).unwrap_or_default().join("Documents/block.solidcraft"); + let title = format!("save {}", path.display()); + say(session, "I'll save the design to your Documents folder."); + tool(session, "t4", &title, "edit", "in_progress", None); + let (out, failed) = m.tool("save", json!({"path": path.to_string_lossy()})); + tool(session, "t4", &title, "edit", if failed { "failed" } else { "completed" }, Some(&out)); + say(session, if failed { "\nThe save didn't go through." } else { "\nSaved." }); + reply(json!({"stopReason": "end_turn"})); + continue; + } + plan(session, 0); + if images > 0 { + say(session, "Thanks for the picture of the view. "); + } + say(session, "I'll make a 40 × 30 × 20 mm block with one batch, round its vertical edges, then measure it."); + if text.contains("ask") { + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "pending", None); + send(&json!({"jsonrpc": "2.0", "id": 900, "method": "session/request_permission", "params": { + "sessionId": session, + // Named as the Claude Code adapter names an MCP tool. + "toolCall": {"toolCallId": "t1", "title": "mcp__solidcraft__batch"}, + "options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}, {"optionId": "reject", "name": "Reject", "kind": "reject_once"}], + }})); + // Wait for the answer (other lines are not expected meanwhile). + let mut allowed = false; + for l in lines.by_ref().map_while(Result::ok) { + let Ok(a) = serde_json::from_str::(l.trim()) else { continue }; + if a.get("id").and_then(Value::as_u64) == Some(900) { + allowed = a.pointer("/result/outcome/optionId").and_then(Value::as_str) == Some("allow"); + break; + } + } + if !allowed { + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "failed", Some("rejected")); + say(session, "\nOK, I left the design as it was."); + reply(json!({"stopReason": "end_turn"})); + continue; + } + } + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "in_progress", None); + let (out, failed) = m.tool( + "batch", + json!({"commands": [{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0], "body_name": "Block"}}], "verbose": false}), + ); + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", if failed { "failed" } else { "completed" }, Some(&out)); + plan(session, 1); + // The block's vertical edges, by a point on each (its corner is at the origin). + let vertical = json!([[0, 0, 10], [40, 0, 10], [40, 30, 10], [0, 30, 10]]); + tool(session, "t2", "execute: solid.fillet r = 4 mm", "edit", "in_progress", None); + let (out, failed) = + m.tool("execute", json!({"command": "solid.fillet", "params": {"edges": vertical, "radius": 4}, "verbose": false})); + tool(session, "t2", "execute: solid.fillet r = 4 mm", "edit", if failed { "failed" } else { "completed" }, Some(&out)); + plan(session, 2); + tool(session, "t3", "measure", "read", "in_progress", None); + let (out, failed) = m.tool("measure", json!({})); + tool(session, "t3", "measure", "read", if failed { "failed" } else { "completed" }, Some(&out)); + plan(session, 3); + say(session, "\nDone: the block is 40 × 30 × 20 mm with 4 mm rounds on its vertical edges."); + reply(json!({"stopReason": "end_turn"})); + } + Some("session/set_mode") => reply(json!({})), + Some("session/cancel") => {} + Some(_) if !id.is_null() => send(&json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": "method not found"}})), + _ => {} + } + } +} diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs new file mode 100644 index 00000000..20814ad5 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -0,0 +1,499 @@ +//! The client side of the Agent Client Protocol (ACP, ), version 1: +//! JSON-RPC 2.0, one message per line over the agent's stdin/stdout. This file only builds and +//! reads messages; the process lives in `process.rs` and the state in `mod.rs`, so everything +//! here is plain data and tested without an agent. +//! +//! What SolidCraft says it can do (`clientCapabilities`): no file system and no terminal (the agent +//! reaches the design through SolidCraft's MCP server instead), and terminal login, so an agent +//! that needs one describes a command for the user to run rather than asking for a key. + +use serde_json::{Value, json}; + +/// The protocol version we speak. +pub const PROTOCOL_VERSION: u64 = 1; + +/// JSON-RPC "method not found", our answer to every agent request we don't serve. +pub const METHOD_NOT_FOUND: i64 = -32601; +/// The error code ACP agents use for "authentication required". +pub const AUTH_REQUIRED: i64 = -32000; + +/// One request or notification to send. +pub fn request(id: u64, method: &str, params: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) +} +pub fn notification(method: &str, params: Value) -> Value { + json!({"jsonrpc": "2.0", "method": method, "params": params}) +} +pub fn reply(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} +pub fn reply_error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +pub fn initialize_params(version: &str) -> Value { + json!({ + "protocolVersion": PROTOCOL_VERSION, + "clientCapabilities": { + "fs": {"readTextFile": false, "writeTextFile": false}, + "terminal": false, + "auth": {"terminal": true}, + "_meta": {"terminal-auth": true}, + }, + "clientInfo": {"name": "solidcraft", "title": "SolidCraft", "version": version}, + }) +} + +/// The MCP server the agent starts to reach the design: `solidcraft-cli mcp --connect` bridged to +/// this app's control port. The server finds the port's token in the token file the app wrote +/// (docs/control-protocol.md), so the token never travels through the agent, and it keeps its +/// default folder limits (docs/mcp.md): no `--allow-any-path`. +#[derive(Clone, Debug, PartialEq)] +pub struct McpBridge { + pub cli: std::path::PathBuf, + pub port: u16, +} + +impl McpBridge { + pub fn to_json(&self) -> Value { + json!({ + "name": "solidcraft", + "command": self.cli.to_string_lossy(), + "args": ["mcp", "--connect", format!("127.0.0.1:{}", self.port)], + "env": [], + }) + } +} + +/// What every session is told about where it is. Agents that don't read `_meta` ignore it. +pub const SYSTEM_NOTE: &str = "You are running inside SolidCraft, a parametric 3D CAD application, as the user's assistant. \ +The open design is reachable through the `solidcraft` MCP server: use its tools (list_commands, batch, execute, inspect_design, \ +measure, screenshot, set_parameter, undo) to read and change the design the user is looking at, and verify with measure or \ +inspect_design. Prefer one `batch` over many small calls. Saving, opening or exporting files waits for the user's approval in \ +the chat, and each message allows a limited number of changes. Don't use shell or file tools: work through the SolidCraft tools. \ +Keep answers short."; + +/// The Claude Code adapter's own tools that reach the computer rather than the design: turned off +/// for the session (`_meta.claudeCode.options.disallowedTools`), so it works through SolidCraft's. +pub const CLAUDE_DISALLOWED_TOOLS: &[&str] = + &["Bash", "BashOutput", "KillShell", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task"]; + +pub fn session_new_params(cwd: &std::path::Path, bridge: Option<&McpBridge>) -> Value { + let servers: Vec = bridge.map(McpBridge::to_json).into_iter().collect(); + json!({ + "cwd": cwd.to_string_lossy(), + "mcpServers": servers, + "_meta": { + "systemPrompt": {"append": SYSTEM_NOTE}, + // Hosts may opt a session out of "bypass permissions" (the Claude adapter reads it): + // every tool call the agent asks about reaches the panel. + // Its own shell and file tools are off: the agent works through SolidCraft's tools. + "claudeCode": {"options": {"allowDangerouslySkipPermissions": false, "disallowedTools": CLAUDE_DISALLOWED_TOOLS}}, + }, + }) +} + +pub fn prompt_params(session: &str, parts: &[super::backend::PromptPart]) -> Value { + json!({"sessionId": session, "prompt": super::backend::content_blocks(parts)}) +} + +/// The agent's modes in a `session/new` reply: the current one and each `(id, name)`. +pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> { + let m = session.get("modes")?; + let current = m.get("currentModeId").and_then(Value::as_str).unwrap_or("").to_string(); + let available: Vec<(String, String)> = m + .get("availableModes")? + .as_array()? + .iter() + .take(16) + .filter_map(|x| { + let id = x.get("id")?.as_str()?.to_string(); + let name = x.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(); + Some((id, name)) + }) + .collect(); + (!available.is_empty()).then_some((current, available)) +} + +/// Whether the agent takes pictures in a message (`agentCapabilities.promptCapabilities.image`). +pub fn takes_images(init: &Value) -> bool { + init.pointer("/agentCapabilities/promptCapabilities/image").and_then(Value::as_bool).unwrap_or(false) +} + +/// One way to log in that the agent offers (`authMethods` in its `initialize` reply). +#[derive(Clone, Debug, PartialEq)] +pub struct AuthMethod { + pub id: String, + pub name: String, + pub description: String, + pub kind: AuthKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AuthKind { + /// The agent signs in by itself when asked (`authenticate`), e.g. by opening a browser. + Agent, + /// The user runs the agent's own login in a terminal: the agent's command plus `args`. + Terminal { args: Vec, command: Option<(String, Vec)> }, + /// The agent reads a key from an environment variable the user sets themselves. + EnvVar { vars: Vec }, +} + +pub fn auth_methods(init: &Value) -> Vec { + let strings = |v: Option<&Value>| -> Vec { + v.and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + init.get("authMethods") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(32) + .filter_map(|m| { + let s = |k: &str| m.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let id = s("id"); + if id.is_empty() { + return None; + } + let kind = match m.get("type").and_then(Value::as_str) { + Some("terminal") => { + let meta = m.get("_meta").and_then(|x| x.get("terminal-auth")); + let command = meta.and_then(|t| Some((t.get("command")?.as_str()?.to_string(), strings(t.get("args"))))); + AuthKind::Terminal { args: strings(m.get("args")), command } + } + Some("env_var") => AuthKind::EnvVar { + vars: m + .get("vars") + .and_then(Value::as_array) + .map(|v| v.iter().take(16).filter_map(|x| x.get("name").and_then(Value::as_str).map(str::to_string)).collect()) + .unwrap_or_default(), + }, + // A key the agent would take from the client: SolidCraft never handles + // keys, so this becomes "set it in your environment" instead. + _ if m.get("_meta").and_then(|x| x.get("api-key")).is_some() => { + let provider = m + .get("_meta") + .and_then(|x| x.get("api-key")) + .and_then(|k| k.get("provider")) + .and_then(Value::as_str) + .unwrap_or("agent"); + AuthKind::EnvVar { vars: vec![format!("{}_API_KEY", provider.to_ascii_uppercase())] } + } + _ => AuthKind::Agent, + }; + Some(AuthMethod { id, name: s("name"), description: s("description"), kind }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The agent's name and version from its `initialize` reply. +pub fn agent_info(init: &Value) -> String { + let i = init.get("agentInfo"); + let s = |k: &str| i.and_then(|i| i.get(k)).and_then(Value::as_str); + match (s("title").or(s("name")), s("version")) { + (Some(n), Some(v)) => format!("{n} {v}"), + (Some(n), None) => n.to_string(), + _ => String::new(), + } +} + +/// A tool call's state as the agent reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolStatus { + Pending, + InProgress, + Completed, + Failed, +} + +impl ToolStatus { + pub fn parse(s: &str) -> Option { + Some(match s { + "pending" => ToolStatus::Pending, + "in_progress" => ToolStatus::InProgress, + "completed" => ToolStatus::Completed, + "failed" => ToolStatus::Failed, + _ => return None, + }) + } +} + +/// One choice of a permission request. +#[derive(Clone, Debug, PartialEq)] +pub struct PermissionOption { + pub id: String, + pub name: String, + /// `allow_once`, `allow_always`, `reject_once` or `reject_always`. + pub kind: String, +} + +/// What a message from the agent means for the panel. +#[derive(Clone, Debug, PartialEq)] +pub enum Incoming { + /// The reply to one of our requests. + Reply { id: u64, result: Result }, + /// Text the agent streams (`thought`: its reasoning, shown dimmed). + Text { thought: bool, text: String }, + /// A new tool call, or news about one (`title`/`status` when they changed). + Tool { id: String, title: Option, kind: Option, status: Option, output: Option }, + /// The agent's plan: each entry with its status. + Plan(Vec<(String, String)>), + /// The agent asks before running a tool call; answered with [`permission_reply`]. + Permission { rpc: Value, title: String, options: Vec }, + /// The agent says whether it is logged in (an extension both adapters send). + AuthStatus(String), + /// The agent switched to another of its modes. + ModeChanged(String), + /// A request we don't serve (file system, terminal, …): answer "method not found". + Unsupported { rpc: Value, method: String }, + /// Anything else (mode and command lists, usage): nothing to show. + Ignored, +} + +/// The longest text kept from one tool call's output. +const MAX_OUTPUT: usize = 2000; + +fn content_text(c: &Value) -> Option { + // A content block, or a tool call content item wrapping one (`{type: "content", content}`). + let block = if c.get("type").and_then(Value::as_str) == Some("content") { c.get("content")? } else { c }; + match block.get("type").and_then(Value::as_str) { + Some("text") => block.get("text").and_then(Value::as_str).map(str::to_string), + Some("image") => Some("[image]".into()), + Some("resource_link") => block.get("uri").and_then(Value::as_str).map(|u| format!("[{u}]")), + Some("diff") => block.get("path").and_then(Value::as_str).map(|p| format!("[diff {p}]")), + Some("terminal") => Some("[terminal]".into()), + _ => None, + } +} + +fn tool_output(u: &Value) -> Option { + let parts: Vec = u.get("content")?.as_array()?.iter().take(16).filter_map(content_text).collect(); + if parts.is_empty() { + return None; + } + let mut s = parts.join("\n"); + if s.len() > MAX_OUTPUT { + let mut cut = MAX_OUTPUT; + while !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + s.push('…'); + } + Some(s) +} + +/// Read one line from the agent. +pub fn parse(line: &str) -> Option { + let msg: Value = serde_json::from_str(line.trim()).ok()?; + let method = msg.get("method").and_then(Value::as_str); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + match (method, msg.get("id")) { + (None, Some(id)) => { + let id = id.as_u64()?; + let result = match msg.get("error") { + Some(e) => { + Err((e.get("code").and_then(Value::as_i64).unwrap_or(0), e.get("message").and_then(Value::as_str).unwrap_or("error").to_string())) + } + None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)), + }; + Some(Incoming::Reply { id, result }) + } + (Some("session/request_permission"), Some(id)) => { + let call = params.get("toolCall"); + let title = call.and_then(|c| c.get("title")).and_then(Value::as_str).unwrap_or("").to_string(); + let options = params + .get("options") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(8) + .filter_map(|o| { + Some(PermissionOption { + id: o.get("optionId")?.as_str()?.to_string(), + name: o.get("name").and_then(Value::as_str).unwrap_or("").to_string(), + kind: o.get("kind").and_then(Value::as_str).unwrap_or("").to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + Some(Incoming::Permission { rpc: id.clone(), title, options }) + } + (Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }), + (Some("session/update"), None) => Some(update(params.get("update")?)), + (Some("_auth/status_update"), None) => { + params.get("authStatus").and_then(|s| s.get("label")).and_then(Value::as_str).map(|l| Incoming::AuthStatus(l.to_string())) + } + _ => Some(Incoming::Ignored), + } +} + +fn update(u: &Value) -> Incoming { + let s = |k: &str| u.get(k).and_then(Value::as_str).map(str::to_string); + match u.get("sessionUpdate").and_then(Value::as_str) { + Some(kind @ ("agent_message_chunk" | "agent_thought_chunk")) => match u.get("content").and_then(content_text) { + Some(text) => Incoming::Text { thought: kind == "agent_thought_chunk", text }, + None => Incoming::Ignored, + }, + Some("tool_call" | "tool_call_update") => match s("toolCallId") { + Some(id) => Incoming::Tool { + id, + title: s("title"), + kind: s("kind"), + status: u.get("status").and_then(Value::as_str).and_then(ToolStatus::parse), + output: tool_output(u), + }, + None => Incoming::Ignored, + }, + Some("current_mode_update") => match s("currentModeId").or_else(|| s("modeId")) { + Some(m) => Incoming::ModeChanged(m), + None => Incoming::Ignored, + }, + Some("plan") => Incoming::Plan( + u.get("entries") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(64) + .map(|e| { + let g = |k: &str| e.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + (g("content"), g("status")) + }) + .collect() + }) + .unwrap_or_default(), + ), + _ => Incoming::Ignored, + } +} + +/// The answer to a permission request: the option picked, or `None` for "cancelled". +pub fn permission_reply(rpc: Value, option: Option<&str>) -> Value { + let outcome = match option { + Some(o) => json!({"outcome": "selected", "optionId": o}), + None => json!({"outcome": "cancelled"}), + }; + reply(rpc, json!({"outcome": outcome})) +} + +/// Split a command line into the program and its arguments: whitespace separates, double or +/// single quotes group, a backslash before a quote keeps it. No shell runs it. +pub fn split_command(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut started = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match (quote, c) { + (_, '\\') if matches!(chars.peek(), Some('"') | Some('\'')) => { + if let Some(n) = chars.next() { + cur.push(n); + } + started = true; + } + (None, '"' | '\'') => { + quote = Some(c); + started = true; + } + (Some(q), _) if c == q => quote = None, + (None, c) if c.is_whitespace() => { + if started { + out.push(std::mem::take(&mut cur)); + started = false; + } + } + _ => { + cur.push(c); + started = true; + } + } + } + if started { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commands_split_like_a_shell_without_one() { + assert_eq!(split_command("gemini --acp"), ["gemini", "--acp"]); + assert_eq!(split_command(r#" "C:\Program Files\x\agent.exe" --flag 'a b' "#), [r"C:\Program Files\x\agent.exe", "--flag", "a b"]); + assert_eq!(split_command(r#"a "" b"#), ["a", "", "b"]); + assert!(split_command(" ").is_empty()); + } + + #[test] + fn the_session_gets_the_bridged_mcp_server_without_a_token_or_any_path() { + let b = McpBridge { cli: "/opt/solidcraft/solidcraft-cli".into(), port: 41234 }; + let p = session_new_params(std::path::Path::new("/home/u/designs"), Some(&b)); + let s = &p["mcpServers"][0]; + assert_eq!(s["name"], "solidcraft"); + assert_eq!(s["args"], json!(["mcp", "--connect", "127.0.0.1:41234"])); + let text = p.to_string(); + assert!(!text.contains("allow-any-path") && !text.contains("TOKEN") && !text.contains("token"), "{text}"); + assert!(p["_meta"]["claudeCode"]["options"]["disallowedTools"].as_array().is_some_and(|t| t.iter().any(|x| x == "Bash"))); + assert_eq!(session_new_params(std::path::Path::new("/tmp"), None)["mcpServers"], json!([])); + } + + #[test] + fn auth_methods_of_both_adapters_are_read() { + // As the Claude adapter offers them to a client with terminal login. + let claude = json!({"authMethods": [{"description": "Use Claude subscription ", "name": "Claude Subscription", "id": "claude-ai-login", + "type": "terminal", "args": ["--cli", "auth", "login", "--claudeai"], + "_meta": {"terminal-auth": {"command": "/usr/bin/node", "args": ["/x/dist/index.js", "--cli", "auth", "login", "--claudeai"], "label": "Claude Login"}}}]}); + let m = auth_methods(&claude); + assert_eq!(m.len(), 1); + let AuthKind::Terminal { args, command } = &m[0].kind else { panic!("{m:?}") }; + assert_eq!(args.last().map(String::as_str), Some("--claudeai")); + assert_eq!(command.as_ref().map(|c| c.0.as_str()), Some("/usr/bin/node")); + // As the Codex adapter offers them. + let codex = json!({"authMethods": [{"id": "chatgpt", "name": "Login with ChatGPT"}, + {"type": "env_var", "id": "openai-api-key", "name": "Use OPENAI_API_KEY", "vars": [{"name": "OPENAI_API_KEY"}]}]}); + let m = auth_methods(&codex); + assert_eq!(m[0].kind, AuthKind::Agent); + // Codex 2.x offers its key login as a method the client would feed: shown as a variable. + let codex2 = json!({"authMethods": [{"id": "api-key", "name": "API Key", "_meta": {"api-key": {"provider": "openai"}}}]}); + assert_eq!(auth_methods(&codex2)[0].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] }); + assert_eq!(m[1].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] }); + } + + #[test] + fn updates_permissions_and_replies_are_told_apart() { + let text = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hi"}}}}"#; + assert_eq!(parse(text), Some(Incoming::Text { thought: false, text: "Hi".into() })); + let tool = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"t1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ok"}}]}}}"#; + assert_eq!( + parse(tool), + Some(Incoming::Tool { id: "t1".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) }) + ); + let perm = r#"{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"toolCallId":"t1","title":"batch"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}"#; + let Some(Incoming::Permission { rpc, title, options }) = parse(perm) else { panic!() }; + assert_eq!((title.as_str(), options.len(), options[1].kind.as_str()), ("batch", 2, "reject_once")); + assert_eq!(permission_reply(rpc.clone(), Some("allow"))["result"]["outcome"], json!({"outcome": "selected", "optionId": "allow"})); + assert_eq!(permission_reply(rpc, None)["result"]["outcome"]["outcome"], "cancelled"); + let fs = r#"{"jsonrpc":"2.0","id":8,"method":"fs/read_text_file","params":{}}"#; + assert!(matches!(parse(fs), Some(Incoming::Unsupported { .. }))); + let err = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"Authentication required"}}"#; + assert_eq!(parse(err), Some(Incoming::Reply { id: 3, result: Err((AUTH_REQUIRED, "Authentication required".into())) })); + assert_eq!(parse("not json"), None); + let mode = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}}"#; + assert_eq!(parse(mode), Some(Incoming::ModeChanged("plan".into()))); + let new = json!({"sessionId": "s", "modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept Edits"}]}}); + assert_eq!(modes(&new), Some(("default".into(), vec![("default".into(), "Default".into()), ("acceptEdits".into(), "Accept Edits".into())]))); + } + + #[test] + fn long_tool_output_is_cut_on_a_character_boundary() { + let long = "é".repeat(3000); + let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t", "content": [{"type": "content", "content": {"type": "text", "text": long}}]}); + let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() }; + assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…')); + } +} diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs new file mode 100644 index 00000000..6e11bd49 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -0,0 +1,246 @@ +//! The ACP backend (desktop only): starts the agent the user picked as a child process and holds +//! the protocol's side of the conversation (initialize, the session, prompts, permission questions, +//! sign-in). The panel sees only [`Event`]s. + +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use super::acp::{self, Incoming}; +use super::backend::{ChatBackend, Event, PromptPart, StartContext}; +use super::process::{AgentProcess, FromAgent}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Pending { + Initialize, + SessionNew, + Prompt, + Authenticate, + SetMode, +} + +/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port +/// would otherwise reach the agent and outrank the chat endpoint's own token file. +const ENV_REMOVE: &[&str] = &["SOLIDCRAFT_CONTROL_TOKEN"]; + +pub struct AcpBackend { + proc: AgentProcess, + cwd: std::path::PathBuf, + bridge: Option, + next_id: u64, + pending: HashMap, + session: Option, + agent: String, + images: bool, + auth: Vec, + /// Open permission questions: our key → the agent's request id. + permissions: HashMap, + next_key: u64, + exited: bool, +} + +impl AcpBackend { + /// Start `argv` and say hello (`initialize`); the session follows when the agent answers. + pub fn start(argv: &[String], ctx: StartContext) -> Result { + let wake = ctx.wake; + let proc = AgentProcess::spawn(argv, &ctx.cwd, ENV_REMOVE, wake)?; + let mut b = AcpBackend { + proc, + cwd: ctx.cwd, + bridge: ctx.bridge, + next_id: 0, + pending: HashMap::new(), + session: None, + agent: String::new(), + images: false, + auth: Vec::new(), + permissions: HashMap::new(), + next_key: 0, + exited: false, + }; + b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))); + Ok(b) + } + + fn request(&mut self, what: Pending, method: &str, params: Value) { + self.next_id += 1; + self.pending.insert(self.next_id, what); + self.proc.send(&acp::request(self.next_id, method, params)); + } + + fn new_session(&mut self) { + let params = acp::session_new_params(&self.cwd, self.bridge.as_ref()); + self.request(Pending::SessionNew, "session/new", params); + } + + fn handle(&mut self, msg: Incoming, out: &mut Vec) { + match msg { + Incoming::Reply { id, result } => { + let Some(what) = self.pending.remove(&id) else { return }; + match (what, result) { + (Pending::Initialize, Ok(v)) => { + self.agent = acp::agent_info(&v); + self.images = acp::takes_images(&v); + self.auth = acp::auth_methods(&v); + self.new_session(); + } + (Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) { + Some(s) => { + self.session = Some(s.to_string()); + if let Some((current, available)) = acp::modes(&v) { + out.push(Event::Modes { current, available }); + } + out.push(Event::Ready { agent: self.agent.clone(), images: self.images }); + } + None => out.push(Event::Error("session/new: no sessionId".into())), + }, + (Pending::Prompt, Ok(v)) => { + let reason = v.get("stopReason").and_then(Value::as_str).unwrap_or(""); + out.push(Event::TurnEnded { reason: if reason == "end_turn" { String::new() } else { reason.to_string() } }); + } + // Signed in: try the session again. + (Pending::Authenticate, Ok(_)) => self.new_session(), + (Pending::SetMode, Ok(_)) => {} + (what, Err((code, message))) => { + let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth"); + if auth { + self.session = None; + if what == Pending::Prompt { + out.push(Event::TurnEnded { reason: "auth".into() }); + } + out.push(Event::AuthRequired { methods: self.auth.clone() }); + } else { + if what == Pending::Prompt { + out.push(Event::TurnEnded { reason: "error".into() }); + } + out.push(Event::Error(message)); + } + } + } + } + Incoming::Text { thought, text } => out.push(Event::Text { thought, text }), + Incoming::Tool { id, title, kind, status, output } => out.push(Event::Tool { id, title, kind, status, output }), + Incoming::Plan(p) => out.push(Event::Plan(p)), + Incoming::Permission { rpc, title, options } => { + self.next_key += 1; + self.permissions.insert(self.next_key, rpc); + out.push(Event::Permission { key: self.next_key, title, options }); + } + Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)), + Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }), + Incoming::Unsupported { rpc, method } => { + self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`"))); + } + Incoming::Ignored => {} + } + } +} + +impl ChatBackend for AcpBackend { + fn poll(&mut self) -> Vec { + let mut out = Vec::new(); + if self.exited { + return out; + } + for _ in 0..512 { + match self.proc.from_agent.try_recv() { + Ok(FromAgent::Line(l)) => { + if let Some(m) = acp::parse(&l) { + self.handle(m, &mut out); + } + } + Ok(FromAgent::Exited(tail)) => { + self.exited = true; + self.session = None; + self.pending.clear(); + out.push(Event::Exited { tail }); + break; + } + Err(_) => break, + } + } + out + } + + fn prompt(&mut self, parts: &[PromptPart]) { + if let Some(s) = self.session.clone() { + self.request(Pending::Prompt, "session/prompt", acp::prompt_params(&s, parts)); + } + } + + fn cancel(&mut self) { + // Open permission questions are answered "cancelled" first, as the protocol asks. + for (_, rpc) in self.permissions.drain() { + self.proc.send(&acp::permission_reply(rpc, None)); + } + if let Some(s) = &self.session { + self.proc.send(&acp::notification("session/cancel", json!({"sessionId": s}))); + } + } + + fn answer_permission(&mut self, key: u64, option: Option<&str>) { + if let Some(rpc) = self.permissions.remove(&key) { + self.proc.send(&acp::permission_reply(rpc, option)); + } + } + + fn authenticate(&mut self, method: &str) { + self.request(Pending::Authenticate, "authenticate", json!({"methodId": method})); + } + + fn set_mode(&mut self, mode: &str) { + if let Some(s) = self.session.clone() { + self.request(Pending::SetMode, "session/set_mode", json!({"sessionId": s, "modeId": mode})); + } + } + + fn pid(&self) -> Option { + self.proc.pid() + } + + fn shutdown(&mut self, wait: bool) { + self.proc.shutdown(wait); + } +} + +/// Test connection: start the agent, say hello, open a session without tools, end it. What it +/// answered (its name and version), or what went wrong (it isn't signed in, it didn't answer). +pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result { + let mut p = AgentProcess::spawn(argv, cwd, ENV_REMOVE, || {})?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let wait_for = |p: &AgentProcess, id: u64| -> Result { + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + match p.from_agent.recv_timeout(left) { + Ok(FromAgent::Line(l)) => { + if let Some(Incoming::Reply { id: got, result }) = acp::parse(&l) + && got == id + { + return result.map_err(|(code, m)| { + if code == acp::AUTH_REQUIRED || m.to_ascii_lowercase().contains("auth") { + crate::i18n::tr("ui.agent_chat_state_auth").to_string() + } else { + m + } + }); + } + } + Ok(FromAgent::Exited(tail)) => { + return Err(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string())); + } + Err(_) => return Err(crate::i18n::tr("ui.ai_no_answer").to_string()), + } + } + }; + p.send(&acp::request(1, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION")))); + let init = wait_for(&p, 1); + let result = init.and_then(|v| { + p.send(&acp::request(2, "session/new", acp::session_new_params(cwd, None))); + wait_for(&p, 2).map(|_| { + let who = acp::agent_info(&v); + crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })]) + }) + }); + p.shutdown(true); + result +} diff --git a/crates/ui-egui/src/agent_chat/agents.rs b/crates/ui-egui/src/agent_chat/agents.rs new file mode 100644 index 00000000..865f7c47 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/agents.rs @@ -0,0 +1,234 @@ +//! The agents the chat can use: the installed ones it finds by itself (Claude Code, Codex, +//! Gemini CLI, OpenCode, …) and the ones the user adds (another ACP command, or an +//! OpenAI-compatible endpoint such as Ollama, LM Studio or OpenAI). Also the permission modes. +//! Preferences only: no design data, no keys (those are in the system keychain). + +use serde::{Deserialize, Serialize}; + +/// How much the agent may do without asking. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Mode { + /// Every change to the design, file and destructive action asks first. + Ask, + /// Modelling commands run; files and destructive actions still ask (the default). + #[default] + Edits, + /// Nothing asks (crash reports and the folder limits still hold). + All, +} + +impl Mode { + pub const ALL: [Mode; 3] = [Mode::Ask, Mode::Edits, Mode::All]; + pub fn id(self) -> &'static str { + match self { + Mode::Ask => "ask", + Mode::Edits => "edits", + Mode::All => "all", + } + } + pub fn parse(s: &str) -> Option { + Mode::ALL.into_iter().find(|m| m.id() == s) + } + pub fn label(self) -> &'static str { + crate::i18n::tr(match self { + Mode::Ask => "ui.ai_mode_ask", + Mode::Edits => "ui.ai_mode_edits", + Mode::All => "ui.ai_mode_all", + }) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + /// A program that speaks the Agent Client Protocol. + #[default] + Acp, + /// An OpenAI-compatible chat completions endpoint. + Api, +} + +/// An agent the user added. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AgentConfig { + pub id: String, + pub name: String, + pub kind: Kind, + /// ACP: the command line. + pub command: String, + /// API: the endpoint (`…/v1`) and model. + pub base_url: String, + pub model: String, + /// API: a key is stored for it in the system keychain. + pub key: bool, +} + +/// An agent as the picker and the switcher show it. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub id: String, + pub name: String, + pub kind: Kind, + /// ACP: what to start. + pub argv: Vec, + pub version: Option, + /// `signed_in`, `key`, `signed_out`, `unknown` (found agents), or empty. + pub login: &'static str, + /// Found installed (not added by the user). + pub found: bool, + pub base_url: String, + pub model: String, + pub key: bool, +} + +impl Entry { + fn from_config(c: &AgentConfig) -> Entry { + Entry { + id: c.id.clone(), + name: if c.name.trim().is_empty() { c.id.clone() } else { c.name.clone() }, + kind: c.kind, + argv: super::acp::split_command(&c.command), + version: None, + login: "", + found: false, + base_url: c.base_url.clone(), + model: c.model.clone(), + key: c.key, + } + } + /// A short line under the name: the version, the model, or the command. + pub fn detail(&self) -> String { + match self.kind { + Kind::Api => format!("{} · {}", self.model, self.base_url), + Kind::Acp => match &self.version { + Some(v) => v.clone(), + None => super::panel::short_command(&self.argv.join(" ")), + }, + } + } +} + +/// Every agent the chat can use: the installed ones found, then the ones the user added. +pub fn entries(app: &crate::SolveApp) -> Vec { + let mut out = Vec::new(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(d) = &app.agent_chat.detection { + out.extend(d.found.iter().map(|f| Entry { + id: f.id.to_string(), + name: f.name.to_string(), + kind: Kind::Acp, + argv: f.argv.clone(), + version: f.version.clone(), + login: f.login.id(), + found: true, + base_url: String::new(), + model: String::new(), + key: false, + })); + } + out.extend(app.preferences.agents.iter().map(Entry::from_config)); + out +} + +/// The agent a conversation uses: the one picked in the panel, else the default, else the first. +pub fn current(app: &crate::SolveApp) -> Option { + let all = entries(app); + let pick = |id: &str| all.iter().find(|e| e.id == id).cloned(); + app.agent_chat.agent_id.as_deref().and_then(pick).or_else(|| pick(&app.preferences.agent_default)).or_else(|| all.first().cloned()) +} + +/// The permission mode for an agent: its own, else the global one. +pub fn mode_for(p: &crate::prefs::Prefs, id: &str) -> Mode { + p.agent_permissions.get(id).copied().unwrap_or(p.ai_permissions) +} + +/// Whether `tool` is always allowed for `agent`. +pub fn always_allowed(p: &crate::prefs::Prefs, agent: &str, tool: &str) -> bool { + p.agent_always_allow.get(agent).is_some_and(|t| t.iter().any(|x| x == tool)) +} + +/// Remember an "always allow" answer. +pub fn remember(p: &mut crate::prefs::Prefs, agent: &str, tool: &str) { + let list = p.agent_always_allow.entry(agent.to_string()).or_default(); + if !list.iter().any(|x| x == tool) && list.len() < 500 { + list.push(tool.chars().take(200).collect()); + } +} + +/// A free id for a new agent: `prefix-1`, `prefix-2`, … +pub fn new_id(p: &crate::prefs::Prefs, prefix: &str) -> String { + (1..).map(|n| format!("{prefix}-{n}")).find(|id| !p.agents.iter().any(|a| &a.id == id)).unwrap_or_else(|| prefix.to_string()) +} + +/// OpenAI-compatible endpoints people use: name, base URL, whether it needs a key. +pub const API_PRESETS: &[(&str, &str, bool)] = &[ + ("Ollama", "http://localhost:11434/v1", false), + ("LM Studio", "http://localhost:1234/v1", false), + ("OpenAI", "https://api.openai.com/v1", true), +]; + +/// Whether a key may be sent to `url`: only over HTTPS, or to this computer. +pub fn key_safe_for(url: &str) -> bool { + let u = url.trim().to_ascii_lowercase(); + if u.starts_with("https://") { + return true; + } + let Some(rest) = u.strip_prefix("http://") else { return false }; + let host = rest.split(['/', '?']).next().unwrap_or(""); + let host = host.rsplit_once('@').map_or(host, |(_, h)| h); + let name = if host.starts_with('[') { + host.split(']').next().map(|h| format!("{h}]")).unwrap_or_default() + } else { + host.split(':').next().unwrap_or("").to_string() + }; + matches!(name.as_str(), "localhost" | "127.0.0.1" | "[::1]") +} + +/// A base URL the API backend accepts: `http(s)://host…`, without a trailing slash. +pub fn normalize_url(url: &str) -> Result { + let u = url.trim().trim_end_matches('/').to_string(); + let lower = u.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) || u.len() < 10 || u.contains(char::is_whitespace) { + return Err(crate::i18n::tr("ui.ai_bad_url").to_string()); + } + Ok(u) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_go_only_over_https_or_to_this_computer() { + assert!(key_safe_for("https://api.openai.com/v1")); + assert!(key_safe_for("http://localhost:11434/v1")); + assert!(key_safe_for("http://127.0.0.1:1234/v1")); + assert!(key_safe_for("http://[::1]:8080/v1")); + assert!(!key_safe_for("http://192.168.1.20:11434/v1")); + assert!(!key_safe_for("http://localhost.evil.example/v1")); + assert!(!key_safe_for("http://user@evil.example/v1")); + assert!(!key_safe_for("ftp://x")); + } + + #[test] + fn modes_and_ids_round_trip() { + for m in Mode::ALL { + assert_eq!(Mode::parse(m.id()), Some(m)); + } + let mut p = crate::prefs::Prefs::default(); + assert_eq!(p.ai_permissions, Mode::Edits, "model edits run, files ask: the default"); + p.agent_permissions.insert("codex".into(), Mode::Ask); + assert_eq!(mode_for(&p, "codex"), Mode::Ask); + assert_eq!(mode_for(&p, "claude"), Mode::Edits); + remember(&mut p, "claude", "mcp__solidcraft__save"); + remember(&mut p, "claude", "mcp__solidcraft__save"); + assert!(always_allowed(&p, "claude", "mcp__solidcraft__save") && !always_allowed(&p, "codex", "mcp__solidcraft__save")); + assert_eq!(p.agent_always_allow["claude"].len(), 1); + p.agents.push(AgentConfig { id: "api-1".into(), ..Default::default() }); + assert_eq!(new_id(&p, "api"), "api-2"); + assert_eq!(normalize_url(" http://localhost:11434/v1/ ").as_deref(), Ok("http://localhost:11434/v1")); + assert!(normalize_url("localhost:11434").is_err()); + } +} diff --git a/crates/ui-egui/src/agent_chat/api_client.rs b/crates/ui-egui/src/agent_chat/api_client.rs new file mode 100644 index 00000000..49a89974 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/api_client.rs @@ -0,0 +1,485 @@ +//! The API backend (desktop only): a model behind an OpenAI-compatible chat completions endpoint +//! (Ollama, LM Studio, OpenAI, any URL the user gives). SolidCraft runs the tool loop itself, with +//! the same tools the ACP agents get: it starts the same MCP server (`solidcraft-cli mcp +//! --connect` to the chat's own endpoint) and is its client, so every tool call passes the same +//! gate, limits and folder checks. The key, when there is one, comes from the system keychain for +//! this connection only. +//! +//! The loop runs on a thread of its own: a model request can take a while. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus}; +use super::process::{AgentProcess, FromAgent, ProcessHandle}; + +/// Model requests for one message, at most (each answer with tool calls is one). +pub const MAX_REQUESTS_PER_TURN: usize = 24; +/// Longest tool result handed back to the model. +const MAX_TOOL_TEXT: usize = 20_000; +/// Messages kept in the conversation (the system note stays). +const MAX_HISTORY: usize = 240; +/// How long one model request may take. +const REQUEST_TIMEOUT_S: u64 = 300; +/// How long one tool call may take (it may wait for the user's Allow). +const TOOL_TIMEOUT: Duration = Duration::from_secs(330); + +/// Tools the model is not offered. +const HIDDEN_TOOLS: &[&str] = &["crash_reports"]; + +pub struct ApiConfig { + pub name: String, + pub base_url: String, + pub model: String, + pub key: Option, + pub http: HttpFn, +} + +enum Cmd { + Prompt(Vec), +} + +pub struct ApiBackend { + cmds: Option>, + events: Receiver, + cancel: Arc, + mcp: Option, +} + +impl ApiBackend { + pub fn start(cfg: ApiConfig, ctx: StartContext) -> Result { + if cfg.key.is_some() && !super::agents::key_safe_for(&cfg.base_url) { + return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string()); + } + // The same MCP server an ACP agent would get. + let mcp = match &ctx.bridge { + Some(b) => { + let argv = vec![b.cli.to_string_lossy().into_owned(), "mcp".into(), "--connect".into(), format!("127.0.0.1:{}", b.port)]; + Some(AgentProcess::spawn(&argv, &ctx.cwd, &["SOLIDCRAFT_CONTROL_TOKEN"], || {})?) + } + None => None, + }; + let handle = mcp.as_ref().map(AgentProcess::handle); + let (cmd_tx, cmd_rx) = channel::(); + let (ev_tx, events) = channel::(); + let cancel = Arc::new(AtomicBool::new(false)); + let stop = cancel.clone(); + let wake: Arc = Arc::from(ctx.wake); + std::thread::spawn(move || { + let mut w = Worker { cfg, mcp, next: 0, tools: Vec::new(), history: Vec::new(), events: ev_tx, wake, cancel: stop }; + w.run(cmd_rx); + }); + Ok(ApiBackend { cmds: Some(cmd_tx), events, cancel, mcp: handle }) + } +} + +impl ChatBackend for ApiBackend { + fn poll(&mut self) -> Vec { + self.events.try_iter().take(512).collect() + } + fn prompt(&mut self, parts: &[PromptPart]) { + self.cancel.store(false, Ordering::Relaxed); + if let Some(c) = &self.cmds { + let _ = c.send(Cmd::Prompt(parts.to_vec())); + } + } + fn cancel(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } + fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {} + fn authenticate(&mut self, _method: &str) {} + fn pid(&self) -> Option { + self.mcp.as_ref().and_then(ProcessHandle::pid) + } + fn shutdown(&mut self, wait: bool) { + self.cancel.store(true, Ordering::Relaxed); + self.cmds = None; + if let Some(h) = self.mcp.take() { + h.shutdown(wait); + } + } +} + +impl Drop for ApiBackend { + fn drop(&mut self) { + self.shutdown(false); + } +} + +struct Worker { + cfg: ApiConfig, + mcp: Option, + next: u64, + /// The MCP tools as OpenAI function tools. + tools: Vec, + history: Vec, + events: Sender, + wake: Arc, + cancel: Arc, +} + +impl Worker { + fn emit(&self, e: Event) { + let _ = self.events.send(e); + (self.wake)(); + } + + fn run(&mut self, cmds: Receiver) { + if let Err(e) = self.start_mcp() { + self.emit(Event::Error(e)); + } + self.history.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE})); + self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true }); + for cmd in cmds { + match cmd { + Cmd::Prompt(parts) => self.turn(parts), + } + } + if let Some(mut m) = self.mcp.take() { + m.shutdown(false); + } + } + + /// One MCP request to the server; its `result`. + fn mcp_call(&mut self, method: &str, params: Value) -> Result { + let Some(m) = self.mcp.as_ref() else { return Err("no SolidCraft tools in this session".into()) }; + self.next += 1; + let id = self.next; + m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + let deadline = std::time::Instant::now() + TOOL_TIMEOUT; + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + match m.from_agent.recv_timeout(left) { + Ok(FromAgent::Line(l)) => { + let Ok(v) = serde_json::from_str::(l.trim()) else { continue }; + if v.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + if let Some(e) = v.get("error") { + return Err(e.get("message").and_then(Value::as_str).unwrap_or("error").to_string()); + } + return Ok(v.get("result").cloned().unwrap_or(Value::Null)); + } + Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()), + Err(_) => return Err("the SolidCraft tools did not answer".into()), + } + } + } + + fn start_mcp(&mut self) -> Result<(), String> { + if self.mcp.is_none() { + return Ok(()); + } + self.mcp_call( + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "solidcraft-agent-chat", "version": env!("CARGO_PKG_VERSION")}}), + )?; + if let Some(m) = &self.mcp { + m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + } + let list = self.mcp_call("tools/list", json!({}))?; + self.tools = openai_tools(&list); + Ok(()) + } + + fn turn(&mut self, parts: Vec) { + self.history.push(user_message(&parts)); + // Keep the system note; drop the oldest messages, never leaving a reply first. + while self.history.len() > MAX_HISTORY { + self.history.remove(1); + } + while self.history.len() > 2 && self.history.get(1).is_some_and(|m| m["role"] != "user") { + self.history.remove(1); + } + for _ in 0..MAX_REQUESTS_PER_TURN { + if self.cancel.load(Ordering::Relaxed) { + self.emit(Event::TurnEnded { reason: "cancelled".into() }); + return; + } + let reply = match self.request() { + Ok(r) => r, + Err(e) => { + self.emit(Event::Error(e)); + self.emit(Event::TurnEnded { reason: "error".into() }); + return; + } + }; + let msg = reply.pointer("/choices/0/message").cloned().unwrap_or(Value::Null); + if let Some(text) = msg.get("content").and_then(Value::as_str).filter(|t| !t.is_empty()) { + self.emit(Event::Text { thought: false, text: text.to_string() }); + } + let calls: Vec = msg.get("tool_calls").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut kept = json!({"role": "assistant", "content": msg.get("content").cloned().unwrap_or(Value::Null)}); + if !calls.is_empty() { + kept["tool_calls"] = Value::Array(calls.clone()); + } + self.history.push(kept); + if calls.is_empty() { + self.emit(Event::TurnEnded { reason: String::new() }); + return; + } + for (i, c) in calls.iter().enumerate().take(32) { + let id = c.get("id").and_then(Value::as_str).map_or_else(|| format!("call-{i}"), str::to_string); + let name = c.pointer("/function/name").and_then(Value::as_str).unwrap_or("").to_string(); + let args: Value = c + .pointer("/function/arguments") + .and_then(|a| if let Some(s) = a.as_str() { serde_json::from_str(s).ok() } else { Some(a.clone()) }) + .unwrap_or(json!({})); + let title = tool_title(&name, &args); + let tool_id = format!("{id}-{}", self.next); + self.emit(Event::Tool { + id: tool_id.clone(), + title: Some(title), + kind: Some(String::new()), + status: Some(ToolStatus::InProgress), + output: None, + }); + let (text, failed) = if self.cancel.load(Ordering::Relaxed) { + ("cancelled by the user".to_string(), true) + } else if HIDDEN_TOOLS.contains(&name.as_str()) + || !self.tools.iter().any(|t| t.pointer("/function/name").and_then(Value::as_str) == Some(name.as_str())) + { + (format!("unknown tool `{name}`"), true) + } else { + match self.mcp_call("tools/call", json!({"name": name, "arguments": args})) { + Ok(r) => tool_text(&r), + Err(e) => (e, true), + } + }; + let shown: String = text.chars().take(2000).collect(); + self.emit(Event::Tool { + id: tool_id, + title: None, + kind: None, + status: Some(if failed { ToolStatus::Failed } else { ToolStatus::Completed }), + output: Some(shown), + }); + self.history.push(json!({"role": "tool", "tool_call_id": id, "content": text})); + } + } + self.emit(Event::Text { thought: false, text: format!("\n({})", crate::i18n::tr("ui.ai_request_limit")) }); + self.emit(Event::TurnEnded { reason: "max_turn_requests".into() }); + } + + fn request(&self) -> Result { + let mut body = json!({"model": self.cfg.model, "messages": self.history}); + if !self.tools.is_empty() { + body["tools"] = Value::Array(self.tools.clone()); + } + let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + if let Some(k) = &self.cfg.key { + headers.push(("Authorization".into(), format!("Bearer {k}"))); + } + let req = HttpRequest { + method: "POST", + url: format!("{}/chat/completions", self.cfg.base_url), + headers, + body: Some(body.to_string().into_bytes()), + timeout_s: REQUEST_TIMEOUT_S, + }; + let (status, bytes) = (self.cfg.http)(&req)?; + let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + if status != 200 { + return Err(http_error(status, &v)); + } + Ok(v) + } +} + +/// An endpoint's refusal as people read it. +pub fn http_error(status: u16, body: &Value) -> String { + let detail = + body.pointer("/error/message").or_else(|| body.get("error")).and_then(Value::as_str).unwrap_or("").chars().take(300).collect::(); + let what = match status { + 401 | 403 => crate::i18n::tr("ui.ai_http_key"), + 404 => crate::i18n::tr("ui.ai_http_not_found"), + 429 => crate::i18n::tr("ui.ai_http_busy"), + _ => "", + }; + format!( + "HTTP {status}{}{}", + if what.is_empty() { String::new() } else { format!(": {what}") }, + if detail.is_empty() { String::new() } else { format!(" ({detail})") } + ) +} + +/// The message for what the user typed (and the view, as a data URL). +fn user_message(parts: &[PromptPart]) -> Value { + let images = parts.iter().any(|p| matches!(p, PromptPart::Image { .. })); + if !images { + let text: Vec<&str> = parts.iter().filter_map(|p| if let PromptPart::Text(t) = p { Some(t.as_str()) } else { None }).collect(); + return json!({"role": "user", "content": text.join("\n")}); + } + let content: Vec = parts + .iter() + .map(|p| match p { + PromptPart::Text(t) => json!({"type": "text", "text": t}), + PromptPart::Image { mime, base64 } => json!({"type": "image_url", "image_url": {"url": format!("data:{mime};base64,{base64}")}}), + }) + .collect(); + json!({"role": "user", "content": content}) +} + +/// MCP `tools/list` as OpenAI function tools (the hidden ones left out). +pub fn openai_tools(list: &Value) -> Vec { + list.get("tools") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(128) + .filter_map(|t| { + let name = t.get("name")?.as_str()?; + if HIDDEN_TOOLS.contains(&name) { + return None; + } + let params = t.get("inputSchema").cloned().filter(Value::is_object).unwrap_or(json!({"type": "object", "properties": {}})); + Some(json!({"type": "function", "function": { + "name": name, + "description": t.get("description").and_then(Value::as_str).unwrap_or(""), + "parameters": params, + }})) + }) + .collect() + }) + .unwrap_or_default() +} + +/// A tool call's result as text for the model, and whether it failed. +fn tool_text(r: &Value) -> (String, bool) { + let failed = r.get("isError").and_then(Value::as_bool).unwrap_or(false); + let parts: Vec = r + .get("content") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(16) + .map(|c| match c.get("type").and_then(Value::as_str) { + Some("text") => c.get("text").and_then(Value::as_str).unwrap_or("").to_string(), + Some("image") => "[a picture of the view]".to_string(), + _ => String::new(), + }) + .collect() + }) + .unwrap_or_default(); + let mut text = parts.join("\n"); + if text.len() > MAX_TOOL_TEXT { + let mut cut = MAX_TOOL_TEXT; + while !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + text.push_str("\n…(cut)"); + } + (text, failed) +} + +/// What a tool call card says: the tool and, for `execute` and `batch`, the commands. +fn tool_title(name: &str, args: &Value) -> String { + match name { + "execute" => format!("execute: {}", args.get("command").and_then(Value::as_str).unwrap_or("?")), + "batch" => { + let ids: Vec<&str> = args + .get("commands") + .and_then(Value::as_array) + .map(|c| c.iter().filter_map(|s| s.get("command").and_then(Value::as_str)).take(4).collect()) + .unwrap_or_default(); + format!("batch: {}", ids.join(", ")) + } + _ => name.to_string(), + } +} + +/// Ask an endpoint for its models (Test connection): how many, and whether `model` is one. +pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result { + let mut headers = Vec::new(); + if let Some(k) = key { + if !super::agents::key_safe_for(base_url) { + return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string()); + } + headers.push(("Authorization".to_string(), format!("Bearer {k}"))); + } + let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 }; + let (status, bytes) = http(&req)?; + let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + if status != 200 { + return Err(http_error(status, &v)); + } + let ids: Vec<&str> = + v.get("data").and_then(Value::as_array).map(|a| a.iter().filter_map(|m| m.get("id").and_then(Value::as_str)).collect()).unwrap_or_default(); + let found = model.is_empty() || ids.contains(&model); + Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &ids.len().to_string()), ("model", model)])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_tools_become_function_tools_without_crash_reports() { + let list = json!({"tools": [ + {"name": "batch", "description": "Run commands", "inputSchema": {"type": "object", "properties": {"commands": {"type": "array"}}}}, + {"name": "crash_reports", "description": "x", "inputSchema": {"type": "object"}}, + {"name": "measure"}, + ]}); + let t = openai_tools(&list); + assert_eq!(t.len(), 2); + assert_eq!(t[0]["function"]["name"], "batch"); + assert_eq!(t[1]["function"]["parameters"]["type"], "object"); + } + + #[test] + fn pictures_go_as_data_urls_and_text_alone_as_a_string() { + assert_eq!(user_message(&[PromptPart::Text("hi".into())])["content"], "hi"); + let m = user_message(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]); + assert_eq!(m["content"][1]["image_url"]["url"], "data:image/png;base64,AA=="); + } + + /// A scripted endpoint: one answer with a tool call, then a plain answer. The loop runs the + /// tool (here without SolidCraft tools: an unknown tool fails) and reports the turn's end. + #[test] + fn the_tool_loop_runs_calls_and_ends_the_turn() { + let answers = std::sync::Mutex::new(vec![ + json!({"choices": [{"message": {"role": "assistant", "content": "Done."}}]}), + json!({"choices": [{"message": {"role": "assistant", "content": "Measuring.", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "measure", "arguments": "{}"}}]}}]}), + ]); + let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); + let log = seen.clone(); + let http: HttpFn = Arc::new(move |r: &HttpRequest| { + log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap()); + let a = answers.lock().unwrap().pop().unwrap_or(json!({})); + Ok((200, a.to_string().into_bytes())) + }); + let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http }; + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut b = ApiBackend::start(cfg, ctx).unwrap(); + b.prompt(&[PromptPart::Text("measure it".into())]); + let mut events = Vec::new(); + let start = std::time::Instant::now(); + while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) { + events.extend(b.poll()); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}"); + assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}"); + assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}"); + assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}"); + // The second request carried the tool's answer. + let sent = seen.lock().unwrap(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[1]["messages"].as_array().unwrap().last().unwrap()["role"], "tool"); + b.shutdown(true); + } + + #[test] + fn keys_never_go_over_plain_http_to_another_computer() { + let http: HttpFn = Arc::new(|_: &HttpRequest| Ok((200, b"{}".to_vec()))); + assert!(test_endpoint(&http, "http://10.0.0.2:11434/v1", Some("k"), "m").is_err()); + let cfg = ApiConfig { name: "x".into(), base_url: "http://10.0.0.2/v1".into(), model: "m".into(), key: Some("k".into()), http }; + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + assert!(ApiBackend::start(cfg, ctx).is_err()); + } +} diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs new file mode 100644 index 00000000..301b9276 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -0,0 +1,154 @@ +//! The seam between the chat panel and whatever answers it. The panel (conversation, sign-in, +//! approvals, turns, undo, the tool gate) only talks to a [`ChatBackend`]; the first backend is +//! [`crate::agent_chat::acp_client::AcpBackend`], which drives an agent the user already has over +//! the Agent Client Protocol. Another backend (one that calls a model API itself, say) slots in +//! by implementing the trait and sending its tool calls into [`StartContext::tools`]: they then +//! pass the same gate, count against the same per-turn limit and undo as one step, exactly like +//! the ACP agent's. + +use serde_json::Value; + +pub use super::acp::{AuthMethod, PermissionOption, ToolStatus}; + +/// One part of a message to the agent. +#[derive(Clone, Debug, PartialEq)] +pub enum PromptPart { + Text(String), + /// A picture of the design (PNG), sent only to agents that take images. + Image { + mime: String, + base64: String, + }, +} + +/// What a backend reports to the panel. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// Ready for a message. `agent`: its name and version; `images`: it takes pictures. + Ready { agent: String, images: bool }, + /// Text it streams (`thought`: its reasoning, shown dimmed). + Text { thought: bool, text: String }, + /// A new tool call, or news about one. + Tool { id: String, title: Option, kind: Option, status: Option, output: Option }, + /// Its plan, replaced whole: each entry with its status. + Plan(Vec<(String, String)>), + /// It asks before running something of its own; answered with [`ChatBackend::answer_permission`]. + Permission { key: u64, title: String, options: Vec }, + /// It needs the user to sign in; `methods` are its own ways to do so. + AuthRequired { methods: Vec }, + /// What it says about its login. + AuthStatus(String), + /// Its own modes (an ACP agent's `modes`: ask, accept edits, plan, …) and the current one. + Modes { current: String, available: Vec<(String, String)> }, + /// The message is answered (`reason`: why it stopped, empty for a normal end). + TurnEnded { reason: String }, + /// Something failed; the backend is still running unless [`Event::Exited`] follows. + Error(String), + /// It ended; its last error output. + Exited { tail: Vec }, +} + +/// What a backend gets to start. +pub struct StartContext { + /// Its working folder: an empty folder of SolidCraft's own, not the user's files. + pub cwd: std::path::PathBuf, + /// The MCP server that reaches this window (`solidcraft-cli mcp --connect` to the chat's own + /// endpoint), for backends that start MCP servers; `None` when it could not be set up. + pub bridge: Option, + /// Where tool calls run in process go, for backends that run their own tool loop. + pub tools: Option>, + /// Wakes the window when something arrives. + pub wake: Box, +} + +/// A chat backend. Methods never block the frame; replies arrive through [`ChatBackend::poll`]. +pub trait ChatBackend { + /// What arrived since the last call. + fn poll(&mut self) -> Vec; + /// Send a message (the panel only sends one after [`Event::Ready`] and while no turn runs). + fn prompt(&mut self, parts: &[PromptPart]); + /// Stop the message being answered; open permission questions count as cancelled. + fn cancel(&mut self); + /// Answer an [`Event::Permission`] with one of its options (`None`: cancelled). + fn answer_permission(&mut self, key: u64, option: Option<&str>); + /// Ask the backend to sign in by itself with one of its methods (it opens the browser). + fn authenticate(&mut self, method: &str); + /// Switch to one of the modes it reported in [`Event::Modes`]. + fn set_mode(&mut self, _mode: &str) {} + /// Its process, for status and tests. + fn pid(&self) -> Option { + None + } + /// End it and everything it started. `wait`: block until done (quitting). + fn shutdown(&mut self, wait: bool); +} + +/// One HTTP request of the API backend (the desktop host sends it; nothing else in the chat +/// reaches the network). +#[derive(Clone, Debug, PartialEq)] +pub struct HttpRequest { + pub method: &'static str, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Option>, + pub timeout_s: u64, +} + +/// Sends an [`HttpRequest`]: the status and the body, or why it failed. +pub type HttpFn = std::sync::Arc Result<(u16, Vec), String> + Send + Sync>; + +/// The system keychain (the desktop host's), for API keys: never in the preferences. +pub trait Secrets: Send + Sync { + fn get(&self, id: &str) -> Result, String>; + fn set(&self, id: &str, secret: &str) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +/// Standard base64 with padding (RFC 4648), for pictures in messages. +pub fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() { + if i <= chunk.len() { + out.push(char::from(T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A'))); + } else { + out.push('='); + } + } + } + out +} + +/// A message's parts as ACP content blocks. +pub fn content_blocks(parts: &[PromptPart]) -> Vec { + parts + .iter() + .map(|p| match p { + PromptPart::Text(t) => serde_json::json!({"type": "text", "text": t}), + PromptPart::Image { mime, base64 } => serde_json::json!({"type": "image", "mimeType": mime, "data": base64}), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_matches_the_rfc_examples() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + assert_eq!(base64(i.as_bytes()), o); + } + } + + #[test] + fn pictures_go_as_image_blocks() { + let b = content_blocks(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]); + assert_eq!(b[1]["type"], "image"); + assert_eq!(b[1]["mimeType"], "image/png"); + } +} diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs new file mode 100644 index 00000000..fcd09660 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -0,0 +1,316 @@ +//! Which ACP agents are installed here (desktop only). A desktop app often starts without the +//! shell's PATH, so the usual install folders are searched too. Detection never starts a login +//! or a session: each agent found is asked for `--version` (with a time limit), and whether it +//! looks logged in comes only from whether its own credentials file exists. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// An agent SolidCraft knows how to start in ACP mode. +pub struct Known { + pub id: &'static str, + pub name: &'static str, + /// Program names to look for, in order of preference. + pub programs: &'static [&'static str], + /// Arguments that put it in ACP mode. + pub args: &'static [&'static str], + /// Its one-line install hint. + pub install: &'static str, + /// Its own credentials file, relative to the home folder (`None`: can't tell), and the + /// variable that moves its settings folder (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`) with the file's + /// name inside it. + pub credentials: Option<&'static str>, + pub config_dir_var: Option<(&'static str, &'static str)>, + /// Variables holding a key it uses instead of a login. + pub key_vars: &'static [&'static str], +} + +/// The agents shown in the picker. The first one found is the default. +pub const KNOWN: &[Known] = &[ + Known { + id: "claude", + name: "Claude Code", + programs: &["claude-agent-acp", "claude-code-acp"], + args: &[], + install: "npm install -g @agentclientprotocol/claude-agent-acp", + credentials: Some(".claude/.credentials.json"), + config_dir_var: Some(("CLAUDE_CONFIG_DIR", ".credentials.json")), + key_vars: &["ANTHROPIC_API_KEY"], + }, + Known { + id: "codex", + name: "Codex", + programs: &["codex-acp"], + args: &[], + install: "npm install -g @agentclientprotocol/codex-acp", + credentials: Some(".codex/auth.json"), + config_dir_var: Some(("CODEX_HOME", "auth.json")), + key_vars: &["OPENAI_API_KEY", "CODEX_API_KEY"], + }, + Known { + id: "gemini", + name: "Gemini CLI", + programs: &["gemini"], + args: &["--acp"], + install: "npm install -g @google/gemini-cli", + credentials: Some(".gemini/oauth_creds.json"), + config_dir_var: None, + key_vars: &["GEMINI_API_KEY", "GOOGLE_API_KEY"], + }, + Known { + id: "opencode", + name: "OpenCode", + programs: &["opencode"], + args: &["acp"], + install: "npm install -g opencode-ai", + credentials: Some(".local/share/opencode/auth.json"), + config_dir_var: None, + key_vars: &[], + }, + Known { + id: "copilot", + name: "GitHub Copilot CLI", + programs: &["copilot"], + args: &["--acp"], + install: "npm install -g @github/copilot", + credentials: None, + config_dir_var: None, + key_vars: &["GH_TOKEN", "GITHUB_TOKEN"], + }, + Known { + id: "qwen", + name: "Qwen Code", + programs: &["qwen"], + args: &["--acp"], + install: "npm install -g @qwen-code/qwen-code", + credentials: Some(".qwen/oauth_creds.json"), + config_dir_var: None, + key_vars: &["DASHSCOPE_API_KEY"], + }, + Known { + id: "goose", + name: "goose", + programs: &["goose"], + args: &["acp"], + install: "see block.github.io/goose (installer script or Homebrew)", + credentials: None, + config_dir_var: None, + key_vars: &[], + }, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Login { + /// Its credentials file is there. + LooksLoggedIn, + /// No login file, but one of its key variables is set. + KeyInEnvironment, + /// Its credentials file is missing (it may still use a key from the environment). + NotLoggedIn, + /// It keeps no file we can look at (system keychain, or unknown). + Unknown, +} + +impl Login { + pub fn id(self) -> &'static str { + match self { + Login::LooksLoggedIn => "signed_in", + Login::KeyInEnvironment => "key", + Login::NotLoggedIn => "signed_out", + Login::Unknown => "unknown", + } + } +} + +/// A known agent that isn't installed, with its one-line install hint. +#[derive(Clone, Debug)] +pub struct Missing { + pub id: &'static str, + pub name: &'static str, + pub install: &'static str, +} + +#[derive(Clone, Debug)] +pub struct Found { + pub id: &'static str, + pub name: &'static str, + /// The full command: the program's path and its ACP arguments. + pub argv: Vec, + pub version: Option, + pub login: Login, +} + +#[derive(Clone, Debug, Default)] +pub struct Detection { + pub found: Vec, + /// Known agents not found. + pub missing: Vec, + pub took_ms: u64, +} + +fn home() -> Option { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).filter(|p| p.is_absolute()) +} + +/// PATH, then the usual places npm, Homebrew, nvm, Volta, bun and pipx put commands. +pub fn search_dirs() -> Vec { + let mut dirs: Vec = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); + if let Some(prefix) = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from) { + dirs.push(if cfg!(windows) { prefix.clone() } else { prefix.join("bin") }); + } + if let Some(h) = home() { + for d in [".local/bin", ".npm-global/bin", ".npm-packages/bin", ".volta/bin", ".bun/bin", ".cargo/bin", "bin"] { + dirs.push(h.join(d)); + } + // nvm: every installed Node version's bin. + if let Ok(rd) = std::fs::read_dir(h.join(".nvm/versions/node")) { + let mut v: Vec = rd.flatten().take(64).map(|e| e.path().join("bin")).collect(); + v.sort(); + dirs.extend(v.into_iter().rev()); + } + } + if cfg!(windows) { + if let Some(a) = std::env::var_os("APPDATA") { + dirs.push(PathBuf::from(a).join("npm")); + } + } else { + for d in ["/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin", "/usr/bin"] { + dirs.push(PathBuf::from(d)); + } + } + let mut seen = std::collections::HashSet::new(); + dirs.retain(|d| d.is_absolute() && seen.insert(d.clone())); + dirs +} + +/// The first `program` in `dirs` (on Windows also with `.exe`/`.cmd`). +pub fn find_in(program: &str, dirs: &[PathBuf]) -> Option { + let names: Vec = + if cfg!(windows) { ["", ".exe", ".cmd", ".bat"].iter().map(|e| format!("{program}{e}")).collect() } else { vec![program.to_string()] }; + dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file()) +} + +/// `program --version`'s first line, or `None` after `limit` (the probe is then stopped). +fn version(program: &Path, limit: Duration) -> Option { + let mut cmd = if cfg!(windows) && program.extension().is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(program); + c + } else { + Command::new(program) + }; + let mut child = cmd.arg("--version").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + let mut out = String::new(); + std::io::Read::read_to_string(&mut child.stdout.take()?, &mut out).ok()?; + let line = out.lines().map(str::trim).find(|l| !l.is_empty())?; + Some(version_of(line)) +} + +/// The version number in a `--version` line (`codex-cli 0.162.1`, `2.1.295 (Claude Code)`, +/// `@scope/pkg 2.2.2`), else the line itself, cut short. +fn version_of(line: &str) -> String { + let v = line.split_whitespace().find(|w| w.trim_start_matches('v').starts_with(|c: char| c.is_ascii_digit())).unwrap_or(line); + v.chars().take(40).collect() +} + +/// Whether `k` looks signed in, from files and variables only: nothing is started or asked. +fn login_of(k: &Known, home: Option<&Path>) -> Login { + let file = match k.config_dir_var.and_then(|(var, name)| std::env::var_os(var).filter(|v| !v.is_empty()).map(|d| PathBuf::from(d).join(name))) { + Some(p) => Some(p), + None => k.credentials.zip(home).map(|(rel, h)| h.join(rel)), + }; + let key = || k.key_vars.iter().any(|v| std::env::var_os(v).is_some_and(|x| !x.is_empty())); + match file { + Some(f) if f.is_file() => Login::LooksLoggedIn, + _ if key() => Login::KeyInEnvironment, + // Claude Code keeps its login in the keychain on macOS: the file says nothing there. + _ if cfg!(target_os = "macos") && k.id == "claude" => Login::Unknown, + Some(_) => Login::NotLoggedIn, + None => Login::Unknown, + } +} + +/// Look for every known agent. Blocking (each probe has a time limit): run it off the UI thread. +pub fn detect() -> Detection { + let t0 = Instant::now(); + let dirs = search_dirs(); + let home = home(); + let mut d = Detection::default(); + for k in KNOWN { + let Some(path) = k.programs.iter().find_map(|p| find_in(p, &dirs)) else { + d.missing.push(Missing { id: k.id, name: k.name, install: k.install }); + continue; + }; + let login = login_of(k, home.as_deref()); + let mut argv = vec![path.to_string_lossy().into_owned()]; + argv.extend(k.args.iter().map(|a| a.to_string())); + d.found.push(Found { id: k.id, name: k.name, version: version(&path, Duration::from_secs(3)), argv, login }); + } + d.took_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX); + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_program_is_found_in_the_first_folder_that_has_it() { + let root = std::env::temp_dir().join(format!("sc-agent-find-{}", std::process::id())); + let (a, b) = (root.join("a"), root.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(b.join("codex-acp"), "").unwrap(); + assert_eq!(find_in("codex-acp", &[a.clone(), b.clone()]), Some(b.join("codex-acp"))); + assert_eq!(find_in("gemini", &[a, b]), None); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn the_version_number_is_picked_out_of_the_line() { + assert_eq!(version_of("@agentclientprotocol/codex-acp 2.2.2"), "2.2.2"); + assert_eq!(version_of("2.1.295 (Claude Code)"), "2.1.295"); + assert_eq!(version_of("goose v1.9.0"), "v1.9.0"); + assert_eq!(version_of("dev build"), "dev build"); + } + + #[test] + fn signed_in_comes_from_the_agents_own_file_or_a_key_variable() { + let home = std::env::temp_dir().join(format!("sc-agent-login-{}", std::process::id())); + std::fs::create_dir_all(home.join(".gemini")).unwrap(); + let gemini = KNOWN.iter().find(|k| k.id == "gemini").unwrap(); + let qwen = KNOWN.iter().find(|k| k.id == "qwen").unwrap(); + let goose = KNOWN.iter().find(|k| k.id == "goose").unwrap(); + if std::env::var_os("GEMINI_API_KEY").is_none() && std::env::var_os("GOOGLE_API_KEY").is_none() { + assert_eq!(login_of(gemini, Some(&home)), Login::NotLoggedIn); + } + std::fs::write(home.join(".gemini/oauth_creds.json"), "{}").unwrap(); + assert_eq!(login_of(gemini, Some(&home)), Login::LooksLoggedIn); + if std::env::var_os("DASHSCOPE_API_KEY").is_none() { + assert_eq!(login_of(qwen, Some(&home)), Login::NotLoggedIn); + } + assert_eq!(login_of(goose, Some(&home)), Login::Unknown, "keeps no file we know"); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn every_known_agent_has_a_program_and_an_install_hint() { + for k in KNOWN { + assert!(!k.programs.is_empty() && !k.install.is_empty(), "{}", k.id); + } + assert_eq!(KNOWN.first().map(|k| k.id), Some("claude"), "the Claude Code adapter is the default"); + } +} diff --git a/crates/ui-egui/src/agent_chat/gate.rs b/crates/ui-egui/src/agent_chat/gate.rs new file mode 100644 index 00000000..6317808e --- /dev/null +++ b/crates/ui-egui/src/agent_chat/gate.rs @@ -0,0 +1,219 @@ +//! What the chat's agent may do to the app. Every request its tools send (whatever the backend) +//! passes here before it runs: a short list of methods (run commands, read the design, move the +//! camera, render), with commands sorted into those that run at once, those that wait for the +//! user's Allow or Deny in the panel (anything that reads or writes files or replaces the design), +//! and those that never run for the agent (crash reports, which hold other designs and paths). +//! Plain data and rules, tested without a window. + +use serde_json::Value; + +/// Changes the agent may make in one turn (requests that run commands; reads don't count). The +/// user can continue with another message. +pub const DEFAULT_TURN_LIMIT: u32 = 24; +/// Requests of any kind in one turn, so an agent stuck reading the design in a loop stops too. +pub const MAX_REQUESTS_PER_TURN: u32 = 400; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + /// Runs now. `change`: it runs a command that may change the design (counted per turn). + Run { change: bool }, + /// Waits for Allow or Deny; the text says what it would do. + Ask(String), + /// Never for the agent; the reason goes back to it. + Refuse(String), +} + +/// Methods the bridged MCP server uses; nothing else (clicks, preferences, quitting, the chat +/// itself) is the agent's. +const METHODS: &[&str] = &["engine.commands", "document.inspect", "engine.execute", "engine.batch", "ui.view", "ui.render", "ui.screenshot"]; + +/// Parameter names that hold a file or folder. +const PATH_KEYS: &[&str] = &["path", "paths", "file", "files", "dir", "folder", "out", "output", "filename", "source", "target_path"]; + +/// Commands that only read (not counted as changes). +fn reads_only(id: &str) -> bool { + id.starts_with("inspect.") + || id.starts_with("model.") + || id.ends_with(".inspect") + || id.ends_with(".list") + || matches!(id, "edit.checkpoint" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers") +} + +fn names_a_file(params: &Value) -> Option { + let o = params.as_object()?; + PATH_KEYS.iter().find_map(|k| match o.get(*k) { + Some(Value::String(s)) if !s.is_empty() => Some(s.chars().take(200).collect()), + Some(Value::Array(a)) if !a.is_empty() => Some(a.iter().filter_map(Value::as_str).take(3).collect::>().join(", ")), + _ => None, + }) +} + +/// One command as the agent would run it. `undo_left`: steps this turn may still undo without +/// reaching changes made before it. +pub fn command(id: &str, params: &Value, undo_left: usize) -> Verdict { + let id = id.trim(); + if id.starts_with("diagnostics.") || id.contains("crash") { + return Verdict::Refuse(format!("`{id}` is not available in the agent chat: crash reports stay with the user")); + } + if id.starts_with("app.") || id.starts_with("prefs.") { + return Verdict::Refuse(format!("`{id}` is not available in the agent chat")); + } + // Files, and commands that replace or reload the whole design. + let file_command = id.starts_with("file.") || id.starts_with("doc.") || matches!(id, "render.export" | "render.start"); + if let Some(p) = names_a_file(params) { + return Verdict::Ask(format!("{id}: {p}")); + } + if file_command && !reads_only(id) { + return Verdict::Ask(id.to_string()); + } + // Undo past this turn's first change takes back the user's own work. + if id == "edit.undo" && undo_left == 0 { + return Verdict::Ask(format!("{id} (a step from before this message)")); + } + Verdict::Run { change: !reads_only(id) } +} + +/// One request from the agent's tools: `method` and its `params` as the control channel has them. +pub fn request(method: &str, params: &Value, undo_left: usize) -> Verdict { + if !METHODS.contains(&method) { + return Verdict::Refuse(format!("`{method}` is not available in the agent chat")); + } + match method { + "engine.execute" => { + let id = params.get("command").and_then(Value::as_str).unwrap_or(""); + command(id, params.get("params").unwrap_or(&Value::Null), undo_left) + } + "engine.batch" => { + let Some(steps) = params.get("commands").and_then(Value::as_array) else { return Verdict::Run { change: false } }; + let mut asks = Vec::new(); + let mut change = false; + // A batch can undo as many steps as it has: count down as it goes. + let mut undo_left = undo_left; + for s in steps.iter().take(10_000) { + let id = s.get("command").or_else(|| s.get("id")).and_then(Value::as_str).unwrap_or(""); + match command(id, s.get("params").unwrap_or(&Value::Null), undo_left) { + Verdict::Refuse(e) => return Verdict::Refuse(e), + Verdict::Ask(a) => asks.push(a), + Verdict::Run { change: c } => { + change |= c; + if id == "edit.undo" { + undo_left = undo_left.saturating_sub(1); + } else if c { + undo_left += 1; + } + } + } + } + if asks.is_empty() { + Verdict::Run { change } + } else { + Verdict::Ask(format!("batch: {}", asks.into_iter().take(6).collect::>().join("; "))) + } + } + // A picture saved to a file names it. + "ui.screenshot" | "ui.render" => match names_a_file(params) { + Some(_) if is_scratch(params) => Verdict::Run { change: false }, + Some(p) => Verdict::Ask(format!("{method}: {p}")), + None => Verdict::Run { change: false }, + }, + _ => Verdict::Run { change: false }, + } +} + +/// What "Always allow" remembers for a request: the command it runs (`batch` for a batch). +pub fn tool_key(method: &str, params: &Value) -> String { + match method { + "engine.execute" => params.get("command").and_then(Value::as_str).unwrap_or("").chars().take(120).collect(), + "engine.batch" => "batch".into(), + m => m.into(), + } +} + +/// A request as an approval card says it: the command, or a batch's commands. +pub fn describe(method: &str, params: &Value) -> String { + match method { + "engine.batch" => { + let steps = params.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + let ids: Vec<&str> = steps.iter().filter_map(|s| s.get("command").or_else(|| s.get("id")).and_then(Value::as_str)).take(6).collect(); + let more = if steps.len() > ids.len() { format!(" (+{})", steps.len() - ids.len()) } else { String::new() }; + format!("batch: {}{more}", ids.join(", ")) + } + _ => tool_key(method, params), + } +} + +/// The MCP server saves the pictures it hands back in its own scratch folder, inside its +/// `allow_paths`; those need no question. +fn is_scratch(params: &Value) -> bool { + let Some(path) = params.get("path").and_then(Value::as_str) else { return false }; + let path = std::path::Path::new(path); + if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return false; + } + let ok = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("png")); + ok && params.get("allow_paths").and_then(Value::as_array).is_some_and(|roots| { + roots + .iter() + .filter_map(Value::as_str) + .map(std::path::Path::new) + .any(|r| r.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("solidcraft-mcp-")) && path.starts_with(r)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn exec(id: &str, params: Value) -> Verdict { + request("engine.execute", &json!({"command": id, "params": params}), 5) + } + + #[test] + fn modelling_runs_and_counts_reads_dont() { + assert_eq!(exec("solid.box", json!({"x": 10})), Verdict::Run { change: true }); + assert_eq!(exec("sketch.inspect", json!({"sketch": 1})), Verdict::Run { change: false }); + assert_eq!(exec("inspect.measure", json!({})), Verdict::Run { change: false }); + assert_eq!(request("document.inspect", &json!({}), 0), Verdict::Run { change: false }); + } + + #[test] + fn files_and_replacing_the_design_ask_first() { + assert!(matches!(exec("file.save", json!({"path": "/tmp/a.solidcraft"})), Verdict::Ask(a) if a.contains("/tmp/a.solidcraft"))); + assert!(matches!(exec("file.new", json!({})), Verdict::Ask(_))); + assert!(matches!(exec("doc.open", json!({"path": "x.step"})), Verdict::Ask(_))); + assert!(matches!(exec("file.export", json!({"path": "x.stl"})), Verdict::Ask(_))); + // Any command given a file, whatever its area. + assert!(matches!(exec("sketch.import", json!({"file": "a.dxf"})), Verdict::Ask(_))); + let batch = json!({"commands": [{"command": "solid.box", "params": {}}, {"command": "file.save", "params": {"path": "/etc/x"}}]}); + assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(a) if a.contains("/etc/x"))); + } + + #[test] + fn crash_reports_and_app_methods_are_never_the_agents() { + assert!(matches!(exec("diagnostics.crash_reports", json!({})), Verdict::Refuse(_))); + assert!(matches!(exec("diagnostics.crash_report", json!({"id": "x"})), Verdict::Refuse(_))); + let batch = json!({"commands": [{"command": "solid.box"}, {"command": "diagnostics.crash_reports"}]}); + assert!(matches!(request("engine.batch", &batch, 0), Verdict::Refuse(_))); + for m in ["app.quit", "ui.click", "ui.prefs", "ui.agent_chat", "engine.script", "ui.key", "ui.file"] { + assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}"); + } + } + + #[test] + fn undo_asks_only_past_the_turns_own_changes() { + assert_eq!(request("engine.execute", &json!({"command": "edit.undo"}), 1), Verdict::Run { change: true }); + assert!(matches!(request("engine.execute", &json!({"command": "edit.undo"}), 0), Verdict::Ask(_))); + let batch = json!({"commands": [{"command": "solid.box"}, {"command": "edit.undo"}, {"command": "edit.undo"}]}); + assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(_))); + } + + #[test] + fn the_mcp_servers_own_pictures_need_no_question() { + let scratch = json!({"path": "/tmp/solidcraft-mcp-41-a3/shot-1.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3"]}); + assert_eq!(request("ui.screenshot", &scratch, 0), Verdict::Run { change: false }); + let elsewhere = json!({"path": "/home/u/Desktop/shot.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3", "/home/u/Desktop"]}); + assert!(matches!(request("ui.screenshot", &elsewhere, 0), Verdict::Ask(_))); + assert_eq!(request("ui.render", &json!({"width": 400}), 0), Verdict::Run { change: false }); + } +} diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs new file mode 100644 index 00000000..af90cfbb --- /dev/null +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -0,0 +1,997 @@ +//! Agent chat: a side panel that talks to a coding agent the user already has (Claude Code, +//! Codex, Gemini CLI, OpenCode, …) over the Agent Client Protocol, and gives that agent +//! SolidCraft's MCP server, bridged to this window, as its way to read and change the open design +//! (docs/agent-chat.md, docs/design/in-app-agent-chat.md). +//! +//! Desktop only: the agent is a child process. Off unless turned on in Preferences. SolidCraft +//! never handles the agent's credentials: an agent that needs a login says so, and the panel +//! shows the agent's own login command for the user to run. +//! +//! - `backend.rs`: the [`backend::ChatBackend`] seam; `acp_client.rs` is the ACP backend. +//! - `gate.rs`: what the agent's tool requests may do (run, ask the user first, or never). +//! - `panel.rs`: the panel itself (docking, minimising, the picker, the conversation). +//! - Here: the conversation, turns (each one undoes as one step), and the requests waiting for +//! Allow or Deny. + +// The web build shows only a note: the process side is desktop only. +#![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))] + +pub mod acp; +#[cfg(not(target_arch = "wasm32"))] +mod acp_client; +pub mod agents; +#[cfg(not(target_arch = "wasm32"))] +mod api_client; +pub mod backend; +#[cfg(not(target_arch = "wasm32"))] +pub mod discover; +pub mod gate; +mod panel; +#[cfg(not(target_arch = "wasm32"))] +mod process; +pub mod settings; + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender}; + +use serde_json::{Value, json}; + +use crate::i18n::{format as trf, tr}; +use crate::{ControlRequest, SolveApp}; +use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus}; + +pub use panel::{show, tip as panel_tip, toggle}; + +/// Most entries kept in the conversation; older ones are dropped. +const MAX_ITEMS: usize = 2000; +/// Longest message sent. +const MAX_PROMPT: usize = 100_000; +/// How long a request waits for Allow or Deny before it is declined (the agent's MCP server gives +/// up on a request after five minutes). +const APPROVAL_TIMEOUT_MS: f64 = 240_000.0; +/// Size of the picture of the view sent with a message. +const VIEW_IMAGE: (usize, usize) = (960, 600); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Off, + Starting, + Ready, + Busy, + NeedsAuth, + Exited, +} + +impl State { + fn id(self) -> &'static str { + match self { + State::Off => "off", + State::Starting => "starting", + State::Ready => "ready", + State::Busy => "busy", + State::NeedsAuth => "auth", + State::Exited => "exited", + } + } + fn label(self) -> &'static str { + tr(match self { + State::Off => "ui.agent_chat_state_off", + State::Starting => "ui.agent_chat_state_starting", + State::Ready => "ui.agent_chat_state_ready", + State::Busy => "ui.agent_chat_state_busy", + State::NeedsAuth => "ui.agent_chat_state_auth", + State::Exited => "ui.agent_chat_state_exited", + }) + } + fn running(self) -> bool { + matches!(self, State::Starting | State::Ready | State::Busy | State::NeedsAuth) + } +} + +#[derive(Clone, Debug)] +pub enum Item { + User { + text: String, + image: bool, + }, + Agent { + thought: bool, + text: String, + }, + Tool { + id: String, + title: String, + kind: String, + status: Option, + output: Option, + }, + Plan(Vec<(String, String)>), + /// The agent asks before running something of its own (its options, its words). + Permission { + key: u64, + title: String, + options: Vec, + answer: Option, + }, + /// A request of the agent's that SolidCraft holds for the user (files, replacing the design, + /// or any change while the mode is "ask"); `tool`: what "Always allow" remembers. + Approval { + id: u64, + title: String, + tool: String, + answer: Option, + }, + /// A message's changes, merged into one undo step; `doc`: the design right after it. + Changes { + steps: usize, + doc: Arc, + undone: bool, + }, + Notice { + text: String, + error: bool, + }, +} + +/// The message being answered: what undo and the limits need. +struct Turn { + /// The design and the undo depth before it. + start_doc: Arc, + start_undo: usize, + /// Requests that ran commands, and requests of any kind. + changes: u32, + requests: u32, + limit_told: bool, + label: String, +} + +/// A request held for Allow or Deny. +struct Held { + id: u64, + req: ControlRequest, + since: f64, +} + +/// Starts the chat's own control endpoint for the agent's MCP server (the desktop host provides +/// it): requests arriving there go to the sender; the port it listens on. +pub type ControlStarter = Box) -> Option>; + +#[derive(Default)] +pub struct AgentChat { + /// The panel is shown (View menu, the application bar's chat button, Ctrl+Shift+A). + pub open: bool, + pub items: Vec, + pub input: String, + state: Option, + agent: String, + images: bool, + /// Send a picture of the view with the next message. + pub attach_view: bool, + auth: Vec, + auth_status: Option, + /// A message typed before the agent was ready. + queued: Option<(String, bool)>, + /// The last message sent, kept to send again after a sign-in. + last: Option<(String, bool)>, + /// The command the running agent was started with. + argv: Vec, + /// The agent picked for this conversation (the header's switcher); `None`: the default. + pub agent_id: Option, + /// The agent the running backend is (for permissions and "always allow"). + running_id: String, + /// The agent's own modes, and the current one. + modes: Vec<(String, String)>, + mode: String, + backend: Option>, + turn: Option, + held: Vec, + next_approval: u64, + /// The chat's control endpoint: the port, and its requests. + pub tools_port: Option, + tools_rx: Option>, + tools_tx: Option>, + /// Settings → AI: the Add agent dialog and connection tests. + pub settings: settings::State, + /// Desktop host hook: start the endpoint on demand. + pub control_starter: Option, + /// The picker is unfolded (it folds to one line once an agent is connected). + picking: Option, + #[cfg(not(target_arch = "wasm32"))] + detection: Option, + #[cfg(not(target_arch = "wasm32"))] + detecting: Option>, +} + +impl Drop for AgentChat { + fn drop(&mut self) { + self.stop_backend(true); + } +} + +impl AgentChat { + pub fn state(&self) -> State { + self.state.unwrap_or(State::Off) + } + fn push(&mut self, item: Item) { + if self.items.len() >= MAX_ITEMS { + self.items.drain(..MAX_ITEMS / 10); + } + self.items.push(item); + } + fn notice(&mut self, text: impl Into, error: bool) { + self.push(Item::Notice { text: text.into(), error }); + } + /// Requests waiting for Allow or Deny. + pub fn waiting(&self) -> usize { + self.held.len() + self.items.iter().filter(|i| matches!(i, Item::Permission { answer: None, .. })).count() + } + + fn stop_backend(&mut self, wait: bool) { + if let Some(mut b) = self.backend.take() { + b.cancel(); + b.shutdown(wait); + } + for h in self.held.drain(..) { + let _ = h.req.reply.send(json!({"ok": false, "error": "the agent chat stopped"})); + } + for i in &mut self.items { + if let Item::Approval { answer: a @ None, .. } = i { + *a = Some(false); + } + if let Item::Permission { answer: a @ None, .. } = i { + *a = Some(tr("ui.agent_chat_cancel").to_string()); + } + } + } + + /// Answer the agent's own permission question at `index` (`None`: cancelled). + pub fn answer(&mut self, index: usize, option: Option<&str>) { + let Some(Item::Permission { key, options, answer, .. }) = self.items.get_mut(index) else { return }; + if answer.is_some() { + return; + } + let label = + option.and_then(|o| options.iter().find(|x| x.id == o)).map_or_else(|| tr("ui.agent_chat_cancel").to_string(), |o| o.name.clone()); + *answer = Some(label); + let key = *key; + if let Some(b) = self.backend.as_mut() { + b.answer_permission(key, option); + } + } + + /// Ask the agent to sign in by itself (an "agent" login method: it opens the browser). + pub fn authenticate(&mut self, method: &str) { + if let Some(b) = self.backend.as_mut() { + b.authenticate(method); + self.state = Some(State::Starting); + } + } + + /// Take in what the backend reported. + fn on_event(&mut self, e: Event) -> Option { + match e { + Event::Ready { agent, images } => { + self.agent = agent; + self.images = images; + self.state = Some(State::Ready); + self.picking = Some(false); + } + Event::Text { thought, text } => { + if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut() + && *t == thought + { + last.push_str(&text); + } else { + self.push(Item::Agent { thought, text }); + } + } + Event::Tool { id, title, kind, status, output } => { + let existing = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id)); + if let Some(Item::Tool { title: t, kind: k, status: s, output: o, .. }) = existing { + if let Some(v) = title { + *t = v; + } + if let Some(v) = kind { + *k = v; + } + if status.is_some() { + *s = status; + } + if output.is_some() { + *o = output; + } + } else { + self.push(Item::Tool { id, title: title.unwrap_or_default(), kind: kind.unwrap_or_default(), status, output }); + } + } + Event::Plan(entries) => { + // A plan is replaced whole: update this turn's plan where it is. + let turn = self.items.iter().rposition(|i| matches!(i, Item::User { .. })).unwrap_or(0); + if let Some(Item::Plan(p)) = self.items.iter_mut().skip(turn).rev().find(|i| matches!(i, Item::Plan(_))) { + *p = entries; + } else { + self.push(Item::Plan(entries)); + } + } + Event::Permission { key, title, options } => return Some(Outcome::Permission { key, title, options }), + Event::Modes { current, available } => { + if !available.is_empty() { + self.modes = available; + } + self.mode = current; + } + Event::AuthRequired { methods } => { + self.auth = methods; + self.state = Some(State::NeedsAuth); + self.picking = Some(false); + // What was asked goes again once the login is done. + if self.queued.is_none() { + self.queued = self.last.take(); + } + } + Event::AuthStatus(s) => self.auth_status = Some(s), + Event::TurnEnded { reason } => { + if self.state() == State::Busy { + self.state = Some(State::Ready); + } + if !matches!(reason.as_str(), "" | "auth" | "error") { + self.notice(format!("({reason})"), false); + } + return Some(Outcome::TurnEnd); + } + Event::Error(e) => { + self.notice(e, true); + if !self.state().running() || self.state() == State::Starting { + self.state = Some(State::Exited); + } + } + Event::Exited { tail } => { + self.state = Some(State::Exited); + let last: Vec = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); + let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) }; + self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true); + self.stop_backend(false); + return Some(Outcome::TurnEnd); + } + } + None + } +} + +/// What an event asks of the app. +enum Outcome { + /// A message was answered (or the agent stopped): the turn's bookkeeping ends. + TurnEnd, + /// The agent asks before running something of its own: answered by the mode, or asked. + Permission { key: u64, title: String, options: Vec }, +} + +/// The AI features are on (the master switch in Settings → AI) and this is the desktop app. +pub fn shown(app: &SolveApp) -> bool { + app.preferences.ai_features && cfg!(not(target_arch = "wasm32")) +} + +/// The AI button at the toolbar row's right end is shown. +pub fn toolbar_button_shown(app: &SolveApp) -> bool { + shown(app) && app.preferences.ai_toolbar_button +} + +/// Whether a permission question is about one of SolidCraft's own tools (which SolidCraft's own +/// gate checks): the agents name MCP tools after their server. +fn solidcraft_tool(title: &str) -> bool { + title.to_ascii_lowercase().contains("solidcraft") +} + +/// `solidcraft-cli` next to this program, else on the search path. +#[cfg(not(target_arch = "wasm32"))] +fn find_cli() -> Option { + let name = if cfg!(windows) { "solidcraft-cli.exe" } else { "solidcraft-cli" }; + if let Some(p) = solidcraft_engine::geom::env::var("CLI").map(std::path::PathBuf::from).filter(|p| p.is_file()) { + return Some(p); + } + std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(|d| d.join(name))) + .filter(|p| p.is_file()) + .or_else(|| discover::find_in("solidcraft-cli", &discover::search_dirs())) +} + +/// The agent's working folder: an empty folder of SolidCraft's own, so an agent's own file tools +/// (where it has them) see nothing of the user's. +fn working_dir() -> std::path::PathBuf { + let d = solidcraft_engine::recovery::data_dir() + .map(|d| d.join("agent-chat").join("work")) + .unwrap_or_else(|| std::env::temp_dir().join("solidcraft-agent-chat")); + #[cfg(not(target_arch = "wasm32"))] + let _ = std::fs::create_dir_all(&d); + d +} + +/// Start the agent: the chat's control endpoint for its MCP bridge if needed, the process. +#[cfg(not(target_arch = "wasm32"))] +pub fn connect(app: &mut SolveApp, ctx: &egui::Context) { + disconnect(app); + let Some(entry) = agents::current(app) else { + app.agent_chat.notice(tr("ui.agent_chat_none_found"), true); + return; + }; + // An API agent's key comes from the keychain for this connection only. + let key = if entry.kind == agents::Kind::Api && entry.key { + match app.services.secrets.as_ref().map(|s| s.get(&entry.id)) { + Some(Ok(k)) => k, + Some(Err(e)) => { + app.agent_chat.notice(trf("ui.ai_keychain_error", &[("error", &e)]), true); + return; + } + None => None, + } + } else { + None + }; + let http = app.services.agent_http.clone(); + let chat = &mut app.agent_chat; + if chat.tools_port.is_none() + && let Some(start) = chat.control_starter.as_mut() + { + let (tx, rx) = std::sync::mpsc::channel(); + if let Some(port) = start(tx.clone()) { + chat.tools_port = Some(port); + chat.tools_rx = Some(rx); + chat.tools_tx = Some(tx); + } + } + let cli = find_cli(); + if chat.tools_port.is_none() { + chat.notice(tr("ui.agent_chat_no_control"), true); + } else if cli.is_none() { + chat.notice(tr("ui.agent_chat_no_cli"), true); + } + let bridge = cli.zip(chat.tools_port).map(|(cli, port)| acp::McpBridge { cli, port }); + let wake = ctx.clone(); + let start = backend::StartContext { cwd: working_dir(), bridge, tools: chat.tools_tx.clone(), wake: Box::new(move || wake.request_repaint()) }; + let started: Result, String> = match entry.kind { + agents::Kind::Acp if entry.argv.is_empty() => Err(tr("ui.agent_chat_none_found").to_string()), + agents::Kind::Acp => acp_client::AcpBackend::start(&entry.argv, start).map(|b| Box::new(b) as Box), + agents::Kind::Api => match http { + Some(http) => api_client::ApiBackend::start( + api_client::ApiConfig { name: entry.name.clone(), base_url: entry.base_url.clone(), model: entry.model.clone(), key, http }, + start, + ) + .map(|b| Box::new(b) as Box), + None => Err(tr("ui.ai_no_http").to_string()), + }, + }; + match started { + Ok(b) => { + chat.backend = Some(b); + chat.argv = entry.argv.clone(); + chat.running_id = entry.id.clone(); + chat.modes.clear(); + chat.mode.clear(); + chat.state = Some(State::Starting); + chat.auth.clear(); + chat.auth_status = None; + chat.agent.clear(); + } + Err(e) => { + chat.notice(trf("ui.agent_chat_failed_start", &[("error", &e)]), true); + chat.state = Some(State::Exited); + } + } +} + +/// Stop the agent (and everything it started); the conversation stays. +pub fn disconnect(app: &mut SolveApp) { + end_turn(app); + let chat = &mut app.agent_chat; + chat.stop_backend(false); + if chat.state.is_some() { + chat.state = Some(State::Off); + } +} + +/// The app is quitting: end the agent and its helpers before the window goes. +pub fn shutdown(app: &mut SolveApp) { + end_turn(app); + app.agent_chat.stop_backend(true); +} + +/// Send what the user typed: now if the agent is ready, else once it is (starting it if needed). +pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) { + let text: String = text.chars().take(MAX_PROMPT).collect(); + if text.trim().is_empty() { + return; + } + let image = app.agent_chat.attach_view && app.agent_chat.images; + app.agent_chat.push(Item::User { text: text.clone(), image }); + match app.agent_chat.state() { + State::Ready => start_turn(app, text, image), + State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false), + s => { + app.agent_chat.queued = Some((text, image)); + #[cfg(not(target_arch = "wasm32"))] + if !s.running() { + connect(app, ctx); + } + #[cfg(target_arch = "wasm32")] + let _ = (s, ctx); + } + } +} + +fn start_turn(app: &mut SolveApp, text: String, image: bool) { + let mut parts = vec![PromptPart::Text(text.clone())]; + if image && let Some(png) = crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) { + parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }); + } + let label: String = text.lines().next().unwrap_or("").chars().take(48).collect(); + app.agent_chat.turn = + Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label }); + app.agent_chat.last = Some((text, image)); + app.agent_chat.state = Some(State::Busy); + if let Some(b) = app.agent_chat.backend.as_mut() { + b.prompt(&parts); + } +} + +/// Ask the agent to stop the message it is on; requests waiting for approval are declined. +pub fn cancel(app: &mut SolveApp) { + decline_all(app, "the user stopped the message"); + if let Some(b) = app.agent_chat.backend.as_mut() { + b.cancel(); + } + for i in &mut app.agent_chat.items { + if let Item::Permission { answer: a @ None, .. } = i { + *a = Some(tr("ui.agent_chat_cancel").to_string()); + } + } +} + +fn decline_all(app: &mut SolveApp, why: &str) { + let ids: Vec = app.agent_chat.held.iter().map(|h| h.id).collect(); + for id in ids { + approve_with(app, id, false, why); + } +} + +/// Allow or deny a held request. +pub fn approve(app: &mut SolveApp, id: u64, allow: bool) { + approve_with(app, id, allow, "the user declined it"); +} + +/// Allow a held request and remember to allow its tool for this agent from now on. +pub fn approve_always(app: &mut SolveApp, id: u64) { + let tool = app.agent_chat.items.iter().find_map(|i| match i { + Item::Approval { id: x, tool, .. } if *x == id => Some(tool.clone()), + _ => None, + }); + if let Some(tool) = tool { + let agent = app.agent_chat.running_id.clone(); + agents::remember(&mut app.preferences, &agent, &tool); + } + approve(app, id, true); +} + +/// Answer the agent's own permission question at `index` with one of its options (`None`: +/// cancelled); an "always allow" answer is remembered for this agent and tool. +pub fn answer_permission(app: &mut SolveApp, index: usize, option: Option<&str>) { + if let (Some(o), Some(Item::Permission { title, options, answer: None, .. })) = (option, app.agent_chat.items.get(index)) + && options.iter().any(|x| x.id == o && x.kind == "allow_always") + { + let (agent, title) = (app.agent_chat.running_id.clone(), title.clone()); + agents::remember(&mut app.preferences, &agent, &title); + } + app.agent_chat.answer(index, option); +} + +/// The agent asks before running something of its own. SolidCraft's own tools are checked by +/// SolidCraft's gate, so they're allowed here (no double question); otherwise the mode or an +/// earlier "always allow" may answer, and the chat says so; otherwise the user is asked. +fn on_permission(app: &mut SolveApp, key: u64, title: String, options: Vec) { + let agent = app.agent_chat.running_id.clone(); + let mode = agents::mode_for(&app.preferences, &agent); + let remembered = agents::always_allowed(&app.preferences, &agent, &title); + let why = if solidcraft_tool(&title) { + Some("ui.ai_auto_ours") + } else if remembered { + Some("ui.ai_auto_remembered") + } else if mode == agents::Mode::All { + Some("ui.ai_auto_mode") + } else { + None + }; + let once = options.iter().find(|o| o.kind == "allow_once"); + let always = options.iter().find(|o| o.kind == "allow_always"); + let pick = if remembered { always.or(once) } else { once.or(always) }.map(|o| o.id.clone()); + match (why, pick) { + (Some(why), Some(option)) => { + if let Some(b) = app.agent_chat.backend.as_mut() { + b.answer_permission(key, Some(&option)); + } + app.agent_chat.push(Item::Notice { text: trf("ui.ai_auto_allowed", &[("title", &title), ("why", tr(why))]), error: false }); + } + _ => app.agent_chat.push(Item::Permission { key, title, options, answer: None }), + } +} + +fn approve_with(app: &mut SolveApp, id: u64, allow: bool, why: &str) { + let chat = &mut app.agent_chat; + let Some(i) = chat.held.iter().position(|h| h.id == id) else { return }; + let h = chat.held.remove(i); + for item in &mut chat.items { + if let Item::Approval { id: x, answer, .. } = item + && *x == id + { + *answer = Some(allow); + } + } + if allow { + app.control_held.push_back(h.req); + } else { + let _ = h.req.reply.send(json!({"ok": false, "error": format!("not run: {why}")})); + } +} + +/// The message is answered: its changes become one undo step, and what still waits is declined. +fn end_turn(app: &mut SolveApp) { + decline_all(app, "the message ended"); + let Some(turn) = app.agent_chat.turn.take() else { return }; + let steps = merge_undo(&mut app.session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)])); + if steps > 0 { + let doc = app.session.doc.clone(); + app.agent_chat.push(Item::Changes { steps, doc, undone: false }); + } +} + +/// Merge the undo steps made since `start_doc` (at depth `start_undo`) into one, labelled `label`; +/// how many steps it took in (0: none were made, or the history no longer reaches that far). +fn merge_undo( + undo: &mut Vec, + start_doc: &Arc, + start_undo: usize, + label: &str, +) -> usize { + // The first change of the turn saved the design as it was when the turn began. When the + // history is full its oldest steps go, so look for it at or below where it was put. + let Some(first) = (0..undo.len().min(start_undo + 1)).rev().find(|&i| undo.get(i).is_some_and(|s| Arc::ptr_eq(&s.doc, start_doc))) else { + return 0; + }; + let steps = undo.len() - first; + undo.truncate(first + 1); + if let Some(s) = undo.get_mut(first) { + s.label = label.to_string(); + } + steps +} + +/// Undo a message's changes from the conversation (only while they are the last thing done). +pub fn undo_changes(app: &mut SolveApp, index: usize) { + let Some(Item::Changes { doc, undone, .. }) = app.agent_chat.items.get(index) else { return }; + if *undone || !Arc::ptr_eq(doc, &app.session.doc) { + return; + } + if app.run("edit.undo", json!({})).is_ok() + && let Some(Item::Changes { undone, .. }) = app.agent_chat.items.get_mut(index) + { + *undone = true; + } +} + +/// The requests the agent's tools sent through the chat's endpoint: each one passes the gate. +fn pump_tools(app: &mut SolveApp) { + let Some(rx) = app.agent_chat.tools_rx.take() else { return }; + let limit = app.preferences.agent_chat_turn_limit.clamp(1, 1000); + for mut req in rx.try_iter().take(64) { + let refuse = |req: ControlRequest, e: String| { + let _ = req.reply.send(json!({"ok": false, "error": e})); + }; + let undo_len = app.session.undo.len(); + let Some(turn) = app.agent_chat.turn.as_mut() else { + refuse(req, "the agent chat has no message in progress".into()); + continue; + }; + turn.requests += 1; + if turn.requests > gate::MAX_REQUESTS_PER_TURN { + refuse(req, format!("too many requests in one message (at most {})", gate::MAX_REQUESTS_PER_TURN)); + continue; + } + // Requests without folders of their own stay inside the chat's working folder. + if let Some(o) = req.params.as_object_mut() + && !o.contains_key("allow_paths") + { + o.insert("allow_paths".into(), json!([working_dir().to_string_lossy()])); + } + let undo_left = undo_len.saturating_sub(turn.start_undo); + // The permission mode (the agent's own, else the global one) and "always allow" answers. + let mode = agents::mode_for(&app.preferences, &app.agent_chat.running_id); + let tool = gate::tool_key(&req.method, &req.params); + let always = agents::always_allowed(&app.preferences, &app.agent_chat.running_id, &tool); + let verdict = match gate::request(&req.method, &req.params, undo_left) { + gate::Verdict::Ask(_) if mode == agents::Mode::All || always => gate::Verdict::Run { change: true }, + gate::Verdict::Run { change: true } if mode == agents::Mode::Ask && !always => { + gate::Verdict::Ask(gate::describe(&req.method, &req.params)) + } + v => v, + }; + match verdict { + gate::Verdict::Run { change } => { + if change { + if turn.changes >= limit { + let first = !turn.limit_told; + turn.limit_told = true; + refuse(req, format!("the limit of {limit} changes for one message is reached: tell the user what is left to do")); + if first { + app.agent_chat.notice(trf("ui.agent_chat_limit", &[("limit", &limit.to_string())]), false); + } + continue; + } + turn.changes += 1; + } + app.control_held.push_back(req); + } + gate::Verdict::Ask(title) => { + let chat = &mut app.agent_chat; + chat.next_approval += 1; + let id = chat.next_approval; + chat.push(Item::Approval { id, title, tool, answer: None }); + chat.held.push(Held { id, req, since: crate::now_ms() }); + } + gate::Verdict::Refuse(e) => { + app.agent_chat.notice(trf("ui.agent_chat_refused", &[("reason", &e)]), false); + refuse(req, e); + } + } + } + app.agent_chat.tools_rx = Some(rx); + // Questions nobody answered: the agent stopped waiting long ago. + let now = crate::now_ms(); + let stale: Vec = app.agent_chat.held.iter().filter(|h| now - h.since > APPROVAL_TIMEOUT_MS).map(|h| h.id).collect(); + for id in stale { + approve_with(app, id, false, "nobody answered in time"); + } +} + +/// Read what the agent sent since the last frame, its tool requests, and the agent search. +pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(rx) = &app.agent_chat.detecting + && let Ok(d) = rx.try_recv() + { + app.agent_chat.detection = Some(d); + app.agent_chat.detecting = None; + } + // AI features turned off: nothing keeps running, nothing is looked for. + if !shown(app) { + if app.agent_chat.backend.is_some() || app.agent_chat.open { + disconnect(app); + app.agent_chat.open = false; + } + return; + } + let events = app.agent_chat.backend.as_mut().map(|b| b.poll()).unwrap_or_default(); + for e in events { + let ready = matches!(e, Event::Ready { .. }); + match app.agent_chat.on_event(e) { + Some(Outcome::TurnEnd) => end_turn(app), + Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options), + None => {} + } + if ready && let Some((text, image)) = app.agent_chat.queued.take() { + start_turn(app, text, image); + } + } + pump_tools(app); + if !app.agent_chat.held.is_empty() || app.agent_chat.state() == State::Busy { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } +} + +/// Look for installed agents in the background (cached; Refresh looks again). +#[cfg(not(target_arch = "wasm32"))] +pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) { + if app.agent_chat.detecting.is_some() { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(discover::detect()); + ctx.request_repaint(); + }); + app.agent_chat.detecting = Some(rx); +} + +/// Switch the agent to one of its own modes (the header's mode picker). +pub fn set_mode(app: &mut SolveApp, mode: &str) { + if app.agent_chat.modes.iter().any(|(id, _)| id == mode) + && let Some(b) = app.agent_chat.backend.as_mut() + { + b.set_mode(mode); + app.agent_chat.mode = mode.to_string(); + } +} + +/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, `open`, +/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `send`, `attach_view`, +/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held +/// request), `cancel`, `undo` (the newest message's changes); returns the panel's state. +pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result { + if let Some(e) = p.get("features").and_then(Value::as_bool) { + app.preferences.ai_features = e; + } + if let Some(b) = p.get("toolbar_button").and_then(Value::as_bool) { + app.preferences.ai_toolbar_button = b; + } + if let Some(m) = p.get("permissions") { + let Some(mode) = m.as_str().and_then(agents::Mode::parse) else { return Err("permissions must be ask, edits or all".into()) }; + app.preferences.ai_permissions = mode; + } + // Add an agent (as the Add agent dialog does), for tests and screenshots: {id?, name, kind, command | base_url + model}. + if let Some(a) = p.get("add_agent") { + let mut c: agents::AgentConfig = serde_json::from_value(a.clone()).map_err(|e| format!("add_agent: {e}"))?; + if c.id.is_empty() { + c.id = agents::new_id(&app.preferences, if c.kind == agents::Kind::Api { "api" } else { "custom" }); + } + c.key = false; + app.preferences.agents.retain(|x| x.id != c.id); + app.preferences.agents.push(c); + } + if let Some(id) = p.get("agent").and_then(Value::as_str) { + if !agents::entries(app).iter().any(|e| e.id == id) { + return Err(format!("no agent `{id}`")); + } + if app.agent_chat.agent_id.as_deref() != Some(id) { + disconnect(app); + app.agent_chat.agent_id = Some(id.to_string()); + } + } + // The Add agent dialog: open on a kind (`installed`, `custom`, `api`), or `false` to close. + if let Some(d) = p.get("add_dialog") { + match d.as_str() { + Some(k) => { + let kind = match k { + "installed" => settings::AddKind::Installed, + "custom" => settings::AddKind::Custom, + "api" => settings::AddKind::Api, + _ => return Err("add_dialog must be installed, custom, api or false".into()), + }; + settings::open_add(app); + if let Some(a) = app.agent_chat.settings.add.as_mut() { + a.kind = kind; + if kind != settings::AddKind::Api { + a.name.clear(); + } + } + } + None => app.agent_chat.settings.add = None, + } + } + if let Some(id) = p.get("default_agent").and_then(Value::as_str) { + app.preferences.agent_default = id.to_string(); + } + if let Some(m) = p.get("mode").and_then(Value::as_str) { + set_mode(app, m); + } + if !shown(app) { + return Ok(status(app)); + } + if let Some(o) = p.get("open").and_then(Value::as_bool) { + app.agent_chat.open = o; + } + if let Some(s) = p.get("side").and_then(Value::as_str) { + if !matches!(s, "left" | "right") { + return Err("side must be left or right".into()); + } + app.preferences.agent_chat_side = s.into(); + } + if let Some(m) = p.get("minimized").and_then(Value::as_bool) { + app.preferences.agent_chat_minimized = m; + } + if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) { + app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH); + panel::forget_width(ctx); + } + if let Some(a) = p.get("attach_view").and_then(Value::as_bool) { + app.agent_chat.attach_view = a; + } + if let Some(pick) = p.get("picker").and_then(Value::as_bool) { + app.agent_chat.picking = Some(pick); + } + #[cfg(not(target_arch = "wasm32"))] + { + if p.get("refresh").and_then(Value::as_bool) == Some(true) { + refresh(app, ctx); + } + if p.get("stop").and_then(Value::as_bool) == Some(true) { + disconnect(app); + } + if p.get("connect").and_then(Value::as_bool) == Some(true) { + connect(app, ctx); + } + } + if let Some(text) = p.get("send").and_then(Value::as_str) { + send(app, ctx, text.to_string()); + } + if let Some(o) = p.get("permission").and_then(Value::as_str) { + let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Permission { answer: None, .. })) else { + return Err("no permission request is waiting".into()); + }; + answer_permission(app, n, (o != "cancel").then_some(o)); + } + if let Some(a) = p.get("approve") { + let Some(id) = app.agent_chat.held.first().map(|h| h.id) else { return Err("no request is waiting for approval".into()) }; + match (a.as_bool(), a.as_str()) { + (Some(allow), _) => approve(app, id, allow), + (_, Some("always")) => approve_always(app, id), + _ => return Err("approve must be true, false or \"always\"".into()), + } + } + if p.get("cancel").and_then(Value::as_bool) == Some(true) { + cancel(app); + } + if p.get("undo").and_then(Value::as_bool) == Some(true) { + let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) }; + undo_changes(app, n); + } + Ok(status(app)) +} + +pub fn status(app: &SolveApp) -> Value { + let c = &app.agent_chat; + let items: Vec = c + .items + .iter() + .map(|i| match i { + Item::User { text, image } => json!({"user": text, "image": image}), + Item::Agent { thought, text } => json!({"agent": text, "thought": thought}), + Item::Tool { id, title, kind, status, output } => { + json!({"tool": id, "title": title, "kind": kind, "status": status.map(|s| format!("{s:?}")), "output": output}) + } + Item::Plan(p) => json!({"plan": p}), + Item::Permission { title, options, answer, .. } => { + json!({"permission": title, "options": options.iter().map(|o| json!({"id": o.id, "name": o.name, "kind": o.kind})).collect::>(), "answer": answer}) + } + Item::Approval { title, answer, .. } => json!({"approval": title, "answer": answer}), + Item::Changes { steps, undone, .. } => json!({"changes": steps, "undone": undone}), + Item::Notice { text, error } => json!({"notice": text, "error": error}), + }) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + let detection = c.detection.as_ref().map(|d| { + json!({ + "found": d.found.iter().map(|f| json!({"id": f.id, "name": f.name, "argv": f.argv, "version": f.version, "login": f.login.id()})).collect::>(), + "missing": d.missing.iter().map(|m| json!({"id": m.id, "install": m.install})).collect::>(), + "ms": d.took_ms, + }) + }); + #[cfg(target_arch = "wasm32")] + let detection: Option = None; + let pid = c.backend.as_ref().and_then(|b| b.pid()); + json!({ + "features": app.preferences.ai_features, + "toolbarButton": app.preferences.ai_toolbar_button, + "permissions": app.preferences.ai_permissions.id(), + "agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::>(), + "currentAgent": agents::current(app).map(|e| e.id), + "modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), + "mode": c.mode, + "open": c.open, + "side": app.preferences.agent_chat_side, + "minimized": app.preferences.agent_chat_minimized, + "width": app.preferences.agent_chat_width, + "state": c.state().id(), + "agent": c.agent, + "images": c.images, + "authStatus": c.auth_status, + "authMethods": c.auth.iter().map(|m| json!({"id": m.id, "name": m.name, "kind": match &m.kind { acp::AuthKind::Agent => "agent", acp::AuthKind::Terminal { .. } => "terminal", acp::AuthKind::EnvVar { .. } => "env_var" }})).collect::>(), + "toolsPort": c.tools_port, + "pid": pid, + "waiting": c.waiting(), + "turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})), + "detection": detection, + "items": items, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs new file mode 100644 index 00000000..44f0ecf2 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -0,0 +1,761 @@ +//! The agent chat panel: docked left or right (switched by dragging its header to the other side, +//! or in Preferences), resizable, folded to a thin strip with Minimise, closed with ×. Its side, +//! width and fold are preferences, so they come back next time. + +use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2}; + +use super::*; +use crate::theme::Tokens; + +pub const MIN_WIDTH: f32 = 280.0; +pub const MAX_WIDTH: f32 = 760.0; +/// The folded panel's width. +const STRIP: f32 = 30.0; +/// The keyboard shortcut that shows and hides the panel (also from its own text field). +pub const SHORTCUT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::A); + +fn panel_id(left: bool) -> Id { + Id::new(if left { "sc_agent_chat_left" } else { "sc_agent_chat_right" }) +} + +/// Forget the panel's own idea of its width, so the preference applies on the next frame. +pub fn forget_width(ctx: &egui::Context) { + ctx.data_mut(|d| { + for left in [true, false] { + d.remove::(panel_id(left)); + } + }); +} + +/// Show or hide the panel (a folded panel unfolds first). +pub fn toggle(app: &mut SolveApp) { + let c = &mut app.agent_chat; + if !c.open { + c.open = true; + app.preferences.agent_chat_minimized = false; + } else if app.preferences.agent_chat_minimized { + app.preferences.agent_chat_minimized = false; + } else { + c.open = false; + } +} + +/// The tooltip of the chat's buttons: its name and shortcut. +pub fn tip() -> String { + let key = if cfg!(target_os = "macos") { "Cmd+Shift+A" } else { "Ctrl+Shift+A" }; + format!("{} ({key})", tr("ui.agent_chat")) +} + +/// The panel, when the chat is turned on and open. Call before the central viewport is laid out. +pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + poll(app, &ctx); + // With AI features off nothing of the chat is drawn, and the shortcut does nothing. + if !shown(app) { + return; + } + super::settings::dialog(app, &ctx); + if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) { + toggle(app); + } + if !app.agent_chat.open { + return; + } + // Look for installed agents the first time the panel opens (in the background). + #[cfg(not(target_arch = "wasm32"))] + if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { + refresh(app, &ctx); + } + let t = Tokens::get(); + let left = app.preferences.agent_chat_side == "left"; + let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) }; + if app.preferences.agent_chat_minimized { + side(Id::new("sc_agent_chat_strip")).exact_size(STRIP).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| strip(app, ui)); + return; + } + let width = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH); + let r = side(panel_id(left)) + .default_size(width) + .size_range(MIN_WIDTH..=MAX_WIDTH) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6))) + .show(ui, |ui| body(app, ui, left)); + crate::scenario::publish_rect("agent_chat:panel", r.response.rect); + let w = r.response.rect.width(); + if w.is_finite() && (MIN_WIDTH..=MAX_WIDTH).contains(&w) { + app.preferences.agent_chat_width = w; + } +} + +/// A small drawn button: `kind` is `minimize`, `close` or `expand`. +fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response { + let t = Tokens::get(); + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + let c = r.center(); + let s = Stroke::new(1.4, t.text); + match kind { + "minimize" => { + ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s); + } + "close" => { + ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s); + ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s); + } + _ => {} + } + crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center()); + resp.on_hover_text(tip) +} + +fn state_color(state: State) -> Color32 { + let t = Tokens::get(); + match state { + State::Ready | State::Busy => t.accent, + State::NeedsAuth | State::Exited => t.warning, + _ => t.text_dim, + } +} + +/// The folded panel: the chat icon (unfolds it), the agent's state, and a mark while something +/// waits for the user. +fn strip(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let r = ui.max_rect(); + let button = Rect::from_center_size(pos2(r.center().x, r.top() + 22.0), vec2(26.0, 26.0)); + let resp = ui.interact(button, Id::new("sc_agent_chat_unfold"), Sense::click()); + crate::scenario::publish_handle("agent_chat:unfold", button.center()); + if resp.hovered() { + ui.painter().rect_filled(button, 4.0, t.hover); + } + crate::icons::paint(ui.painter(), button.shrink(4.0), "agent_chat", t.icon, t.icon_fill, t.accent); + let state = app.agent_chat.state(); + ui.painter().circle_filled(pos2(r.center().x, button.bottom() + 10.0), 3.5, state_color(state)); + let waiting = app.agent_chat.waiting(); + if waiting > 0 { + let at = button.right_top() + vec2(-3.0, 3.0); + ui.painter().circle_filled(at, 6.5, t.warning); + ui.painter().text(at, Align2::CENTER_CENTER, waiting.min(9).to_string(), egui::FontId::proportional(9.5), t.panel); + } + ui.painter().line_segment( + if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] }, + Stroke::new(1.0, t.border), + ); + let tip = format!("{} · {}", tip(), state.label()); + if resp.on_hover_text(tip).clicked() { + app.preferences.agent_chat_minimized = false; + } +} + +/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop, +/// Minimise and Close. +fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { + let t = Tokens::get(); + let state = app.agent_chat.state(); + ui.horizontal(|ui| { + let title = { + let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover()); + for row in 0..3 { + for col in 0..2 { + ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim); + } + } + let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0)); + grip.union(l.rect) + }; + let mut s = state.label().to_string(); + if let Some(turn) = &app.agent_chat.turn + && state == State::Busy + { + let limit = app.preferences.agent_chat_turn_limit; + s = format!("{s} {}", trf("ui.agent_chat_changes_of", &[("n", &turn.changes.to_string()), ("limit", &limit.to_string())])); + } + ui.label(RichText::new(s).color(state_color(state)).small()); + // Drag the header across the window to dock on the other side. + let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag()); + crate::scenario::publish_handle("agent_chat:grip", title.center()); + dock_drag(app, ui, &drag, left); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() { + app.agent_chat.open = false; + } + if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() { + app.preferences.agent_chat_minimized = true; + } + #[cfg(not(target_arch = "wasm32"))] + { + let b = if state.running() { + ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip")) + } else { + ui.button(tr("ui.agent_chat_connect")) + }; + crate::scenario::publish_handle("agent_chat:connect", b.rect.center()); + if b.clicked() { + if state.running() { + disconnect(app); + } else { + connect(app, ui.ctx()); + } + } + } + }); + }); +} + +/// While the header is dragged: the cursor grabs and the side it would dock on lights up; on +/// release over the other half of the window the panel moves there. +fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: bool) { + if drag.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + let screen = ui.ctx().content_rect(); + let target = |x: f32| x < screen.center().x; + if drag.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let to_left = target(p.x); + let w = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH); + let top = screen.top() + crate::toolbar::APP_BAR_HEIGHT; + let zone = if to_left { + Rect::from_min_max(pos2(screen.left(), top), pos2(screen.left() + w, screen.bottom())) + } else { + Rect::from_min_max(pos2(screen.right() - w, top), pos2(screen.right(), screen.bottom())) + }; + let t = Tokens::get(); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("sc_agent_chat_dock_hint"))); + painter.rect(zone.shrink(4.0), 8.0, t.accent.gamma_multiply(0.18), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + painter.text( + zone.center(), + Align2::CENTER_CENTER, + tr(if to_left { "ui.agent_chat_dock_left" } else { "ui.agent_chat_dock_right" }), + egui::FontId::proportional(14.0), + t.text, + ); + } + if drag.drag_stopped() + && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) + && target(p.x) != left + { + app.preferences.agent_chat_side = if left { "right" } else { "left" }.into(); + } +} + +fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { + header(app, ui, left); + #[cfg(not(target_arch = "wasm32"))] + switcher(app, ui); + #[cfg(target_arch = "wasm32")] + { + ui.label(tr("ui.agent_chat_desktop_only")); + } + #[cfg(not(target_arch = "wasm32"))] + desktop_body(app, ui); +} + +#[cfg(not(target_arch = "wasm32"))] +fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { + if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { + refresh(app, ui.ctx()); + } + let state = app.agent_chat.state(); + picker(app, ui); + if state == State::NeedsAuth { + auth_box(app, ui); + } + ui.separator(); + // The input sits at the bottom; the conversation takes the rest. + let input_h = 92.0; + let avail = (ui.available_height() - input_h).max(80.0); + egui::ScrollArea::vertical().id_salt("sc_agent_chat_scroll").max_height(avail).auto_shrink([false, false]).stick_to_bottom(true).show(ui, |ui| { + conversation(app, ui); + }); + ui.separator(); + let busy = state == State::Busy; + let edit = egui::TextEdit::multiline(&mut app.agent_chat.input) + .hint_text(tr("ui.agent_chat_placeholder")) + .desired_rows(2) + .desired_width(f32::INFINITY) + .id_salt("sc_agent_chat_input"); + let r = ui.add(edit); + crate::scenario::publish_rect("agent_chat:input", r.rect); + let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift); + ui.horizontal(|ui| { + let send_b = ui.add_enabled(!busy, egui::Button::new(tr("ui.agent_chat_send"))); + crate::scenario::publish_handle("agent_chat:send", send_b.rect.center()); + if (send_b.clicked() || (enter && !busy)) && !app.agent_chat.input.trim().is_empty() { + let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string(); + send(app, ui.ctx(), text); + } + if busy && ui.button(tr("ui.agent_chat_cancel")).clicked() { + cancel(app); + } + // A picture of the view goes with the message to agents that take pictures. + let images = app.agent_chat.images || !state.running(); + let attach = ui + .add_enabled(images, egui::Checkbox::new(&mut app.agent_chat.attach_view, tr("ui.agent_chat_attach_view"))) + .on_hover_text(tr("ui.agent_chat_attach_view_tip")) + .on_disabled_hover_text(tr("ui.agent_chat_no_images")); + crate::scenario::publish_handle("agent_chat:attach", attach.rect.center()); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn login_chip(ui: &mut egui::Ui, login: &str) { + let t = Tokens::get(); + let (text, color) = match login { + "signed_in" => (tr("ui.agent_chat_signed_in"), t.accent), + "key" => (tr("ui.agent_chat_key_in_env"), t.accent), + "signed_out" => (tr("ui.agent_chat_not_signed_in"), t.warning), + _ => return, + }; + egui::Frame::NONE.stroke(Stroke::new(1.0, color)).corner_radius(8.0).inner_margin(egui::Margin::symmetric(6, 1)).show(ui, |ui| { + ui.label(RichText::new(text).color(color).size(11.0)); + }); +} + +/// A small mark for an agent's kind: a terminal prompt for a program, a cloud for an endpoint. +pub(super) fn kind_icon(ui: &mut egui::Ui, kind: agents::Kind) { + let t = Tokens::get(); + let (r, _) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::hover()); + let p = ui.painter(); + let s = Stroke::new(1.3, t.text_dim); + match kind { + agents::Kind::Acp => { + p.rect_stroke(r.shrink(1.0), 2.0, s, egui::StrokeKind::Inside); + p.line_segment([r.left_top() + vec2(4.0, 4.0), r.left_top() + vec2(7.0, 7.0)], s); + p.line_segment([r.left_top() + vec2(7.0, 7.0), r.left_top() + vec2(4.0, 10.0)], s); + p.line_segment([r.left_top() + vec2(8.5, 10.0), r.left_top() + vec2(12.0, 10.0)], s); + } + agents::Kind::Api => { + let c = r.center(); + p.circle_stroke(c + vec2(-3.0, 1.5), 3.2, s); + p.circle_stroke(c + vec2(1.0, -1.0), 4.2, s); + p.circle_stroke(c + vec2(4.0, 2.0), 2.8, s); + } + } +} + +/// The header's second row: the agent this conversation uses (a compact switcher), the agent's +/// own mode when it has modes, and the state of its login. +#[cfg(not(target_arch = "wasm32"))] +fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let all = agents::entries(app); + let current = agents::current(app); + ui.horizontal(|ui| { + let label = current.as_ref().map_or_else( + || tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(), + |e| e.name.clone(), + ); + let w = (ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 120.0 }).clamp(120.0, 260.0); + let mut picked: Option = None; + let mut add = false; + let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| { + for e in &all { + let sel = current.as_ref().is_some_and(|c| c.id == e.id); + let r = ui.horizontal(|ui| { + kind_icon(ui, e.kind); + let r = ui.selectable_label(sel, &e.name); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + login_chip(ui, e.login); + r + }); + crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.inner.rect.center()); + if r.inner.clicked() { + picked = Some(e.id.clone()); + } + } + ui.separator(); + if ui.selectable_label(false, tr("ui.ai_add_agent")).clicked() { + add = true; + } + if ui.selectable_label(false, tr("ui.ai_settings")).clicked() { + crate::prefs::open_at(app, crate::prefs::PAGE_AI); + } + }); + crate::scenario::publish_handle("agent_chat:switcher", combo.response.rect.center()); + combo.response.on_hover_text(tr("ui.ai_switch_tip")); + if add { + super::settings::open_add(app); + } + if let Some(id) = picked + && current.as_ref().is_none_or(|c| c.id != id) + { + disconnect(app); + app.agent_chat.agent_id = Some(id); + } + // The agent's own modes (ACP `modes`): its own tools follow them. + if !app.agent_chat.modes.is_empty() { + let modes = app.agent_chat.modes.clone(); + let cur = modes.iter().find(|(id, _)| *id == app.agent_chat.mode).map_or_else(|| app.agent_chat.mode.clone(), |(_, n)| n.clone()); + let mut pick: Option = None; + let r = egui::ComboBox::from_id_salt("sc_agent_mode").width(100.0).selected_text(cur).show_ui(ui, |ui| { + for (id, name) in &modes { + if ui.selectable_label(*id == app.agent_chat.mode, name).clicked() { + pick = Some(id.clone()); + } + } + }); + crate::scenario::publish_handle("agent_chat:mode", r.response.rect.center()); + r.response.on_hover_text(tr("ui.ai_agent_mode_tip")); + if let Some(m) = pick { + set_mode(app, &m); + } + } + }); + if let Some(a) = &app.agent_chat.auth_status { + ui.label(RichText::new(a).color(t.text_dim).size(11.0)); + } +} + +/// Before the first connection: what's installed, how to install the rest, and Add agent. +#[cfg(not(target_arch = "wasm32"))] +fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let open = app.agent_chat.picking.unwrap_or(!app.agent_chat.state().running() && app.agent_chat.items.is_empty()); + if !open { + return; + } + let all = agents::entries(app); + let current = agents::current(app); + ui.horizontal(|ui| { + ui.label(RichText::new(tr("ui.agent_chat_pick")).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let refreshing = app.agent_chat.detecting.is_some(); + if ui.add_enabled(!refreshing, egui::Button::new(tr("ui.agent_chat_refresh")).small()).clicked() { + refresh(app, ui.ctx()); + } + if ui.small_button(tr("ui.ai_add_agent")).clicked() { + super::settings::open_add(app); + } + }); + }); + if app.agent_chat.detecting.is_some() { + ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim)); + } + for e in &all { + let selected = current.as_ref().is_some_and(|c| c.id == e.id); + let fill = if selected { t.accent_soft } else { Color32::TRANSPARENT }; + let r = egui::Frame::NONE.fill(fill).corner_radius(5.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + kind_icon(ui, e.kind); + ui.label(RichText::new(&e.name).strong().color(t.text)); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| login_chip(ui, e.login)); + }); + }); + let resp = ui.interact(r.response.rect, Id::new(("sc_agent_pick", &e.id)), Sense::click()); + crate::scenario::publish_handle(&format!("agent_chat:pick:{}", e.id), resp.rect.center()); + if resp.clicked() { + app.agent_chat.agent_id = Some(e.id.clone()); + } + } + let missing: Vec = app.agent_chat.detection.as_ref().map(|d| d.missing.clone()).unwrap_or_default(); + if !missing.is_empty() { + let heading = if all.is_empty() { tr("ui.agent_chat_none_found") } else { tr("ui.agent_chat_not_installed") }; + egui::CollapsingHeader::new(RichText::new(heading).color(t.text_dim)).id_salt("sc_agent_missing").default_open(all.is_empty()).show( + ui, + |ui| { + for m in missing { + ui.horizontal(|ui| { + ui.label(RichText::new(m.name).strong()); + if ui.small_button(tr("ui.agent_chat_copy")).on_hover_text(m.install).clicked() { + ui.ctx().copy_text(m.install.to_string()); + } + }); + ui.add(egui::Label::new(RichText::new(m.install).monospace().size(11.0).color(t.text_dim)).selectable(true).wrap()); + } + }, + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.warning)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(tr("ui.agent_chat_auth_title")).strong()); + ui.label(RichText::new(tr("ui.agent_chat_auth_needed")).color(t.text_dim)); + let methods = app.agent_chat.auth.clone(); + let base = app.agent_chat.argv.iter().map(|a| quote(a)).collect::>().join(" "); + if methods.is_empty() { + ui.label(tr("ui.agent_chat_auth_none")); + } + for m in methods { + ui.add_space(4.0); + let desc = m.description.trim(); + ui.label(RichText::new(if desc.is_empty() || desc == m.name { m.name.clone() } else { format!("{} — {desc}", m.name) }).strong()); + match &m.kind { + acp::AuthKind::Terminal { args, .. } => { + ui.label(tr("ui.agent_chat_auth_terminal")); + let cmd = format!("{base} {}", args.iter().map(|a| quote(a)).collect::>().join(" ")); + ui.horizontal_wrapped(|ui| { + egui::Frame::NONE.fill(t.toolbar).corner_radius(4.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(&cmd).monospace().size(11.0)).selectable(true).wrap()); + }); + if ui.small_button(tr("ui.agent_chat_copy")).clicked() { + ui.ctx().copy_text(cmd.clone()); + } + }); + } + acp::AuthKind::Agent => { + let b = ui.button(trf("ui.agent_chat_auth_agent", &[("name", &m.name)])); + crate::scenario::publish_handle(&format!("agent_chat:auth:{}", m.id), b.rect.center()); + if b.clicked() { + app.agent_chat.authenticate(&m.id); + } + } + acp::AuthKind::EnvVar { vars } => { + ui.label(trf("ui.agent_chat_auth_env", &[("vars", &vars.join(", "))])); + } + } + } + ui.add_space(4.0); + if ui.button(tr("ui.agent_chat_auth_retry")).clicked() { + connect(app, ui.ctx()); + } + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let mut answer: Option<(usize, Option)> = None; + // Allow (true), Deny (false), Always allow (None). + let mut approval: Option<(u64, Option)> = None; + let mut undo: Option = None; + let current_doc = app.session.doc.clone(); + for (n, item) in app.agent_chat.items.iter().enumerate() { + match item { + Item::User { text, image } => { + ui.add_space(6.0); + egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(text).color(t.text)).selectable(true).wrap()); + if *image { + ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0)); + } + }); + } + Item::Agent { thought, text } => { + let rt = if *thought { RichText::new(text).italics().color(t.text_dim) } else { RichText::new(text).color(t.text) }; + ui.add(egui::Label::new(rt).selectable(true).wrap()); + } + Item::Tool { title, kind, status, output, .. } => { + tool_card(ui, n, title, kind, *status, output.as_deref()); + } + Item::Plan(entries) => { + for (c, s) in entries { + let (color, filled) = match s.as_str() { + "completed" => (t.accent, true), + "in_progress" => (t.warning, true), + _ => (t.text_dim, false), + }; + ui.horizontal(|ui| { + status_dot(ui, color, filled); + ui.label(RichText::new(c).color(t.text_dim)); + }); + } + } + Item::Permission { title, options, answer: given, .. } => { + egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.accent)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(trf("ui.agent_chat_permission", &[("title", title)])).strong()); + match given { + Some(a) => { + ui.label(RichText::new(trf("ui.agent_chat_answered", &[("option", a)])).color(t.text_dim)); + } + None => { + ui.horizontal_wrapped(|ui| { + for o in options { + let b = egui::Button::new(&o.name); + let b = if o.kind.starts_with("allow") { b.fill(t.accent_soft) } else { b }; + let r = ui.add(b); + crate::scenario::publish_handle(&format!("agent_chat:permission:{}", o.id), r.rect.center()); + if r.clicked() { + answer = Some((n, Some(o.id.clone()))); + } + } + }); + } + } + }); + } + Item::Approval { id, title, tool, answer: given } => { + egui::Frame::group(ui.style()).stroke(Stroke::new(1.5, t.warning)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(tr("ui.agent_chat_approval")).strong()); + ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5)).selectable(true).wrap()); + match given { + Some(true) => { + ui.label(RichText::new(tr("ui.agent_chat_allowed")).color(t.text_dim)); + } + Some(false) => { + ui.label(RichText::new(tr("ui.agent_chat_denied")).color(t.text_dim)); + } + None => { + ui.horizontal(|ui| { + let allow = ui.add(egui::Button::new(tr("ui.agent_chat_allow")).fill(t.accent_soft)); + let always = ui.button(tr("ui.ai_always_allow")).on_hover_text(trf("ui.ai_always_allow_tip", &[("tool", tool)])); + let deny = ui.button(tr("ui.agent_chat_deny")); + crate::scenario::publish_handle("agent_chat:allow", allow.rect.center()); + crate::scenario::publish_handle("agent_chat:always", always.rect.center()); + crate::scenario::publish_handle("agent_chat:deny", deny.rect.center()); + if allow.clicked() { + approval = Some((*id, Some(true))); + } + if always.clicked() { + approval = Some((*id, None)); + } + if deny.clicked() { + approval = Some((*id, Some(false))); + } + }); + } + } + }); + } + Item::Changes { steps, doc, undone } => { + ui.horizontal(|ui| { + let text = trf("ui.agent_chat_changes", &[("n", &steps.to_string())]); + ui.label(RichText::new(text).color(t.text_dim).size(11.5)); + if *undone { + ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(11.5)); + } else if Arc::ptr_eq(doc, ¤t_doc) { + let b = ui.button(tr("ui.agent_chat_undo_changes")).on_hover_text(tr("ui.ai_undo_tip")); + crate::scenario::publish_handle("agent_chat:undo", b.rect.center()); + if b.clicked() { + undo = Some(n); + } + } + }); + } + Item::Notice { text, error } => { + ui.label(RichText::new(text).color(if *error { t.error } else { t.text_dim })); + } + } + ui.add_space(2.0); + } + if app.agent_chat.items.is_empty() { + ui.add_space(12.0); + ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim)); + ui.add_space(6.0); + ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0)); + } + if let Some((n, o)) = answer { + answer_permission(app, n, o.as_deref()); + } + match approval { + Some((id, Some(allow))) => approve(app, id, allow), + Some((id, None)) => approve_always(app, id), + None => {} + } + if let Some(n) = undo { + undo_changes(app, n); + } +} + +/// A tool call as a card: its state, name and kind on one line; a click unfolds what it returned. +#[cfg(not(target_arch = "wasm32"))] +fn tool_card(ui: &mut egui::Ui, n: usize, title: &str, kind: &str, status: Option, output: Option<&str>) { + let t = Tokens::get(); + let (color, filled, state) = match status { + Some(ToolStatus::Completed) => (t.accent, true, "ui.ai_tool_done"), + Some(ToolStatus::Failed) => (t.error, true, "ui.ai_tool_failed"), + Some(ToolStatus::InProgress) => (t.warning, true, "ui.ai_tool_running"), + _ => (t.text_dim, false, "ui.ai_tool_waiting"), + }; + egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(6, 3)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + let id = ui.make_persistent_id(("sc_agent_tool", n)); + let mut state_ = egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false); + let head = ui.horizontal(|ui| { + status_dot(ui, color, filled); + // The state and kind keep their room at the right; the title is cut short before them. + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label(RichText::new(tr(state)).size(10.5).color(color)); + if !kind.is_empty() && kind != "other" { + ui.label(RichText::new(kind).size(10.5).color(t.text_dim)); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5).color(t.text)).truncate()).on_hover_text(title); + }); + }); + }); + let click = ui.interact(head.response.rect, id.with("head"), Sense::click()); + crate::scenario::publish_handle(&format!("agent_chat:tool:{n}"), head.response.rect.center()); + if output.is_some() && click.clicked() { + state_.toggle(ui); + } + if output.is_some() { + click.on_hover_text(tr("ui.ai_tool_unfold")); + } + state_.show_body_unindented(ui, |ui| { + if let Some(o) = output { + ui.separator(); + egui::ScrollArea::vertical().id_salt(("sc_agent_tool_out", n)).max_height(180.0).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(o).monospace().size(11.0)).selectable(true).wrap()); + }); + } + }); + }, + ); +} + +/// A small status mark drawn as a shape (no glyph a system font might lack): filled when the +/// step is running or done, hollow while it waits. +#[cfg(not(target_arch = "wasm32"))] +fn status_dot(ui: &mut egui::Ui, color: Color32, filled: bool) { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + if filled { + ui.painter().circle_filled(r.center(), 4.0, color); + } else { + ui.painter().circle_stroke(r.center(), 3.5, Stroke::new(1.2, color)); + } +} + +/// A custom command as the picker shows it: the program's file name and its arguments, cut short. +pub(super) fn short_command(line: &str) -> String { + let argv = acp::split_command(line); + let mut parts: Vec = argv.iter().map(|a| a.rsplit(['/', '\\']).next().unwrap_or(a).to_string()).collect(); + if parts.is_empty() { + parts.push(line.to_string()); + } + let s = parts.join(" "); + if s.chars().count() > 40 { format!("{}…", s.chars().take(40).collect::()) } else { s } +} + +/// Quote an argument for the command field when it holds spaces. +pub(super) fn quote(a: &str) -> String { + if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') { format!("\"{}\"", a.replace('"', "\\\"")) } else { a.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_commands_show_short_in_the_picker() { + assert_eq!(short_command("python3 -I /very/long/path/to/mock_agent.py"), "python3 -I mock_agent.py"); + assert!(short_command(&"x".repeat(100)).ends_with('…')); + } + + #[test] + fn the_shortcut_shows_folds_and_hides() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + toggle(&mut app); + assert!(app.agent_chat.open); + app.preferences.agent_chat_minimized = true; + toggle(&mut app); + assert!(app.agent_chat.open && !app.preferences.agent_chat_minimized, "a folded panel unfolds"); + toggle(&mut app); + assert!(!app.agent_chat.open); + } +} diff --git a/crates/ui-egui/src/agent_chat/process.rs b/crates/ui-egui/src/agent_chat/process.rs new file mode 100644 index 00000000..4fc64484 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/process.rs @@ -0,0 +1,318 @@ +//! The agent as a child process (desktop only): its stdout is read on a thread and handed to the +//! panel line by line, its stdin written on another so a slow agent never blocks a frame. +//! +//! Agents start helpers of their own (Node adapters start the CLI they wrap, which starts the MCP +//! servers it was given), so stopping only the program we started would leave those running. The +//! agent therefore gets a process group of its own on Unix (on Windows, `taskkill /T` walks its +//! tree), and [`AgentProcess::shutdown`] ends the whole group: stdin is closed first (an ACP agent +//! ends at end of input), then the group is asked to stop, then stopped. Everything is addressed +//! by the ids of the processes we started, never by name. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::{Duration, Instant}; + +/// Longest line read from the agent; a longer one is dropped. +const MAX_LINE: usize = 8 << 20; +/// Lines of the agent's stderr kept to explain a failed start. +const STDERR_LINES: usize = 40; +/// How long an agent gets to end by itself once its input is closed, and then once asked to stop. +const GRACE: Duration = Duration::from_millis(1200); + +pub enum FromAgent { + Line(String), + /// stdout closed: the agent ended. Its last stderr lines, for the panel. + Exited(Vec), +} + +pub struct AgentProcess { + child: ProcessHandle, + to_agent: Option>, + pub from_agent: Receiver, +} + +/// Ends the process from another thread than the one that talks to it. +#[derive(Clone)] +pub struct ProcessHandle(std::sync::Arc>>); + +impl ProcessHandle { + pub fn pid(&self) -> Option { + self.0.lock().ok().and_then(|c| c.as_ref().map(Child::id)) + } + /// End it and everything it started; `wait`: block until done. + pub fn shutdown(&self, wait: bool) { + let Some(child) = self.0.lock().ok().and_then(|mut c| c.take()) else { return }; + if wait { + end_tree(child); + } else { + std::thread::spawn(move || end_tree(child)); + } + } +} + +/// On Windows, npm installs `.cmd` shims, which only `cmd.exe` runs. +fn command_for(program: &str) -> Command { + let lower = program.to_ascii_lowercase(); + if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(program); + c + } else { + Command::new(program) + } +} + +/// Start the agent in a process group of its own (Unix) or without a console window (Windows). +fn detach_group(cmd: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: no console flashes up for a console agent. + cmd.creation_flags(0x0800_0000); + } +} + +/// Signal the agent's whole process group (`signal`: `TERM` or `KILL`). The group's id is the +/// agent's own process id, as it was started with `process_group(0)`. +#[cfg(unix)] +fn signal_group(pgid: u32, signal: &str) { + if pgid <= 1 { + return; + } + let mut c = Command::new("kill"); + c.args(["-s", signal]); + // procps' kill reads `-123` as an option without `--`; BSD kill takes it as is. + if cfg!(target_os = "linux") { + c.arg("--"); + } + let _ = c.arg(format!("-{pgid}")).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status(); +} + +/// Windows: end the agent and every process it started. +#[cfg(windows)] +fn kill_tree(pid: u32) { + use std::os::windows::process::CommandExt; + let _ = Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .creation_flags(0x0800_0000) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Wait up to `limit` for the child to end; whether it did. +fn wait_for(child: &mut Child, limit: Duration) -> bool { + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => return false, + Err(_) => return true, + } + } +} + +/// End the agent and everything it started (see the module note). Blocks for at most about +/// three seconds. +fn end_tree(mut child: Child) { + let pid = child.id(); + // Its input is already closed: a well-behaved agent ends now and stops its own helpers. + let ended = wait_for(&mut child, GRACE); + #[cfg(unix)] + { + // Helpers can outlive the agent: the group is signalled whether or not it ended. The + // group id stays reserved while any member lives, so it can't name someone else's. + signal_group(pid, "TERM"); + if !ended { + wait_for(&mut child, GRACE); + } + signal_group(pid, "KILL"); + } + #[cfg(windows)] + { + if !ended { + kill_tree(pid); + } + } + #[cfg(not(any(unix, windows)))] + let _ = (pid, ended); + let _ = child.kill(); + let _ = child.wait(); +} + +impl AgentProcess { + /// Start `argv` (program first) in `cwd`; `wake` is called whenever a line arrives. + /// `env_remove`: variables the agent must not inherit. + pub fn spawn( + argv: &[String], + cwd: &std::path::Path, + env_remove: &[&str], + wake: impl Fn() + Send + Sync + 'static, + ) -> Result { + let Some((program, args)) = argv.split_first() else { return Err("no agent command".into()) }; + let mut cmd = command_for(program); + cmd.args(args).current_dir(cwd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + for v in env_remove { + cmd.env_remove(v); + } + detach_group(&mut cmd); + let mut child = cmd.spawn().map_err(|e| format!("{program}: {e}"))?; + let (Some(mut stdin), Some(stdout), Some(stderr)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else { + end_tree(child); + return Err("the agent's pipes could not be opened".into()); + }; + let (to_agent, rx) = channel::(); + std::thread::spawn(move || { + for line in rx { + if stdin.write_all(line.as_bytes()).and_then(|()| stdin.write_all(b"\n")).and_then(|()| stdin.flush()).is_err() { + break; + } + } + // The sender is gone: stdin closes here, which is how an ACP agent learns to end. + }); + let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::::new())); + let tail_w = tail.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if let Ok(mut t) = tail_w.lock() { + if t.len() >= STDERR_LINES { + t.pop_front(); + } + t.push_back(line.chars().take(400).collect()); + } + } + }); + let (tx, from_agent) = channel::(); + let wake = std::sync::Arc::new(wake); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut buf = Vec::new(); + match (&mut reader).take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf) { + Ok(0) | Err(_) => break, + Ok(_) if buf.len() > MAX_LINE => { + // Skip the rest of an oversized line. + let mut rest = Vec::new(); + if reader.read_until(b'\n', &mut rest).is_err() { + break; + } + } + Ok(_) => { + if tx.send(FromAgent::Line(String::from_utf8_lossy(&buf).into_owned())).is_err() { + return; + } + wake(); + } + } + } + // Give stderr a moment to deliver the agent's last words. + std::thread::sleep(Duration::from_millis(100)); + let lines = tail.lock().map(|t| t.iter().cloned().collect()).unwrap_or_default(); + let _ = tx.send(FromAgent::Exited(lines)); + wake(); + }); + Ok(AgentProcess { child: ProcessHandle(std::sync::Arc::new(std::sync::Mutex::new(Some(child)))), to_agent: Some(to_agent), from_agent }) + } + + pub fn send(&self, msg: &serde_json::Value) { + if let Some(t) = &self.to_agent { + let _ = t.send(msg.to_string()); + } + } + + pub fn pid(&self) -> Option { + self.child.pid() + } + + pub fn handle(&self) -> ProcessHandle { + self.child.clone() + } + + /// End the agent and its helpers. `wait`: block until they are gone (quitting the app); + /// otherwise the ending runs on a thread of its own and this returns at once. + pub fn shutdown(&mut self, wait: bool) { + self.to_agent = None; + self.child.shutdown(wait); + } +} + +impl Drop for AgentProcess { + fn drop(&mut self) { + self.shutdown(false); + } +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::*; + + fn alive(pid: u32) -> bool { + // `kill -0` checks that the process exists without touching it. A zombie still counts + // on Linux, so /proc's state is checked there too. + #[cfg(target_os = "linux")] + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + return !stat.rsplit(')').next().is_some_and(|s| s.trim_start().starts_with('Z')); + } + Command::new("kill").args(["-0", &pid.to_string()]).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) + } + + /// Start a fake agent that leaves helpers behind, read the helpers' ids from it, end it. + fn helpers_after_shutdown(script: &str) -> Vec { + let dir = std::env::temp_dir(); + let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; + let mut p = AgentProcess::spawn(&argv, &dir, &[], || {}).unwrap(); + let mut pids = vec![p.pid().unwrap()]; + while pids.len() < 3 { + match p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() { + FromAgent::Line(l) => pids.push(l.trim().parse().unwrap()), + FromAgent::Exited(tail) => panic!("the fake agent ended early: {tail:?}"), + } + } + assert!(pids.iter().all(|&pid| alive(pid)), "all running before the shutdown: {pids:?}"); + p.shutdown(true); + // Orphaned helpers are reaped by init a moment after they end. + let start = Instant::now(); + while pids.iter().any(|&pid| alive(pid)) && start.elapsed() < Duration::from_secs(5) { + std::thread::sleep(Duration::from_millis(50)); + } + pids.into_iter().filter(|&pid| alive(pid)).collect() + } + + /// No orphans: an agent that started helpers (as Node adapters start the CLI they wrap and + /// its MCP servers) is ended with all of them, whether it ends politely at end of input or + /// ignores both its input and the request to stop. + #[test] + fn stopping_the_agent_leaves_no_helpers_running() { + // A polite agent: ends when its input closes; its helpers would live on by themselves. + let polite = "sleep 300 & echo $!; sleep 300 & echo $!; cat >/dev/null"; + assert_eq!(helpers_after_shutdown(polite), Vec::::new(), "polite agent"); + // A stubborn one: ignores end of input and TERM (so do its helpers). + let stubborn = "trap '' TERM; (trap '' TERM; sleep 300) & echo $!; (trap '' TERM; sleep 300) & echo $!; while :; do sleep 1; done"; + assert_eq!(helpers_after_shutdown(stubborn), Vec::::new(), "stubborn agent"); + } + + /// Dropping the process (closing the panel's connection any other way) ends it too. + #[test] + fn dropping_the_process_ends_its_group() { + let argv = vec!["sh".to_string(), "-c".to_string(), "sleep 300 & echo $!; wait".to_string()]; + let p = AgentProcess::spawn(&argv, &std::env::temp_dir(), &[], || {}).unwrap(); + let leader = p.pid().unwrap(); + let FromAgent::Line(l) = p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() else { panic!("no helper id") }; + let helper: u32 = l.trim().parse().unwrap(); + drop(p); + let start = Instant::now(); + while (alive(helper) || alive(leader)) && start.elapsed() < Duration::from_secs(8) { + std::thread::sleep(Duration::from_millis(50)); + } + assert!(!alive(helper) && !alive(leader), "leader {leader} or helper {helper} still running"); + } +} diff --git a/crates/ui-egui/src/agent_chat/settings.rs b/crates/ui-egui/src/agent_chat/settings.rs new file mode 100644 index 00000000..4109a537 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/settings.rs @@ -0,0 +1,541 @@ +//! Settings → AI: the master switch, the agents (found and added, each with Test, Default and, +//! for added ones, Remove), permissions (global and per agent, and the "always allow" answers), +//! and the panel. Plus the Add agent dialog: an installed ACP agent, a custom command, or an +//! OpenAI-compatible endpoint (whose key goes to the system keychain). + +use egui::{RichText, Stroke}; + +use super::agents::{self, AgentConfig, Kind, Mode}; +use super::*; +use crate::theme::Tokens; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AddKind { + #[default] + Installed, + Custom, + Api, +} + +/// The Add agent dialog's fields. +#[derive(Default)] +pub struct AddState { + pub kind: AddKind, + pub name: String, + pub command: String, + pub installed: usize, + pub preset: usize, + pub base_url: String, + pub model: String, + pub key: String, + pub error: Option, + test: Option, +} + +/// A connection test running or done. +pub enum Test { + Running(std::sync::mpsc::Receiver>), + Done(Result), +} + +impl Test { + fn poll(&mut self) { + if let Test::Running(rx) = self + && let Ok(r) = rx.try_recv() + { + *self = Test::Done(r); + } + } +} + +/// Settings state that isn't preferences: the dialog and the tests. +#[derive(Default)] +pub struct State { + pub add: Option, + pub tests: std::collections::HashMap, +} + +/// Open the Add agent dialog. +pub fn open_add(app: &mut SolveApp) { + let preset = agents::API_PRESETS.first(); + app.agent_chat.settings.add = Some(AddState { + base_url: preset.map(|p| p.1.to_string()).unwrap_or_default(), + name: preset.map(|p| p.0.to_string()).unwrap_or_default(), + ..Default::default() + }); +} + +/// Run a connection test in the background (it starts the agent briefly, or asks the endpoint +/// for its models). +#[cfg(not(target_arch = "wasm32"))] +fn start_test(app: &SolveApp, e: &agents::Entry, key: Option) -> Test { + let (tx, rx) = std::sync::mpsc::channel(); + match e.kind { + Kind::Acp => { + let argv = e.argv.clone(); + std::thread::spawn(move || { + let _ = tx.send(super::acp_client::test_agent(&argv, &working_dir())); + }); + } + Kind::Api => { + let Some(http) = app.services.agent_http.clone() else { return Test::Done(Err(tr("ui.ai_no_http").to_string())) }; + let (url, model) = (e.base_url.clone(), e.model.clone()); + std::thread::spawn(move || { + let _ = tx.send(super::api_client::test_endpoint(&http, &url, key.as_deref(), &model)); + }); + } + } + Test::Running(rx) +} + +fn test_line(ui: &mut egui::Ui, t: &Option<&Test>) { + let tk = Tokens::get(); + match t { + Some(Test::Running(_)) => { + ui.label(RichText::new(tr("ui.ai_testing")).color(tk.text_dim).size(11.0)); + } + Some(Test::Done(Ok(m))) => { + ui.label(RichText::new(m.clone()).color(tk.accent).size(11.0)); + } + Some(Test::Done(Err(e))) => { + ui.add(egui::Label::new(RichText::new(e).color(tk.error).size(11.0)).wrap()); + } + None => {} + } +} + +fn section(ui: &mut egui::Ui, title: &str) { + ui.add_space(10.0); + ui.label(RichText::new(title).strong().size(13.0)); + ui.separator(); +} + +fn note(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(); + ui.add(egui::Label::new(RichText::new(text).size(11.0).color(t.text_dim)).wrap()); +} + +/// The AI page of Settings. +pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let r = ui.checkbox(&mut app.preferences.ai_features, RichText::new(tr("ui.ai_show_features")).strong()); + crate::scenario::publish_handle("prefs:ai_features", r.rect.center()); + note(ui, tr("ui.ai_show_features_note")); + if cfg!(target_arch = "wasm32") { + note(ui, tr("ui.agent_chat_desktop_only")); + return; + } + if !app.preferences.ai_features { + return; + } + ui.add_enabled_ui(true, |ui| { + let r = ui.checkbox(&mut app.preferences.ai_toolbar_button, tr("ui.ai_toolbar_button")); + crate::scenario::publish_handle("prefs:ai_toolbar_button", r.rect.center()); + }); + note(ui, tr("ui.ai_toolbar_button_note")); + #[cfg(not(target_arch = "wasm32"))] + { + if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { + refresh(app, &ui.ctx().clone()); + } + agents_section(app, ui); + } + section(ui, tr("ui.ai_permissions")); + for m in Mode::ALL { + let r = ui.radio_value(&mut app.preferences.ai_permissions, m, m.label()); + crate::scenario::publish_handle(&format!("prefs:ai_mode:{}", m.id()), r.rect.center()); + note( + ui, + tr(match m { + Mode::Ask => "ui.ai_mode_ask_note", + Mode::Edits => "ui.ai_mode_edits_note", + Mode::All => "ui.ai_mode_all_note", + }), + ); + } + if app.preferences.ai_permissions == Mode::All { + egui::Frame::NONE.stroke(Stroke::new(1.0, t.warning)).corner_radius(4.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(tr("ui.ai_mode_all_warning")).color(t.warning)).wrap()); + }); + } + note(ui, tr("ui.ai_modes_scope")); + // "Always allow" answers. + let remembered: Vec<(String, Vec)> = + app.preferences.agent_always_allow.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| (k.clone(), v.clone())).collect(); + ui.add_space(4.0); + ui.label(RichText::new(tr("ui.ai_always_allowed")).strong()); + if remembered.is_empty() { + note(ui, tr("ui.ai_always_allowed_none")); + } + let names: std::collections::HashMap = agents::entries(app).into_iter().map(|e| (e.id, e.name)).collect(); + for (agent, tools) in remembered { + ui.horizontal_wrapped(|ui| { + ui.label(names.get(&agent).cloned().unwrap_or_else(|| agent.clone())); + for tool in &tools { + ui.label(RichText::new(tool).monospace().size(11.0).color(t.text_dim)); + } + if ui.small_button(tr("ui.ai_reset")).clicked() { + app.preferences.agent_always_allow.remove(&agent); + } + }); + } + section(ui, tr("ui.ai_panel")); + egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| { + ui.label(tr("ui.agent_chat_side")); + ui.horizontal(|ui| { + let side = &mut app.preferences.agent_chat_side; + if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() { + *side = "left".into(); + } + if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() { + *side = "right".into(); + } + }); + ui.end_row(); + ui.label(tr("ui.agent_chat_turn_limit")); + ui.add(egui::DragValue::new(&mut app.preferences.agent_chat_turn_limit).range(1..=200)); + ui.end_row(); + }); + ui.add_space(6.0); + note(ui, tr("ui.agent_chat_privacy_note")); +} + +/// The agents: found and added, with Test, Default, a permission override and Remove. +#[cfg(not(target_arch = "wasm32"))] +fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + section(ui, tr("ui.ai_agents")); + for test in app.agent_chat.settings.tests.values_mut() { + test.poll(); + } + let all = agents::entries(app); + let d = &app.preferences.agent_default; + let default = if all.iter().any(|e| &e.id == d) { Some(d.clone()) } else { all.first().map(|e| e.id.clone()) }; + if app.agent_chat.detecting.is_some() { + ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim)); + } else if all.is_empty() { + note(ui, tr("ui.agent_chat_none_found")); + } + let mut remove: Option = None; + let mut test: Option = None; + for e in &all { + egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).corner_radius(5.0).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + super::panel::kind_icon(ui, e.kind); + ui.label(RichText::new(&e.name).strong()); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + super::panel::login_chip(ui, e.login); + if !e.found { + ui.label(RichText::new(tr("ui.ai_added")).color(t.text_dim).size(11.0)); + } + }); + ui.horizontal(|ui| { + let is_default = default.as_deref() == Some(e.id.as_str()); + if ui.radio(is_default, tr("ui.ai_default")).clicked() { + app.preferences.agent_default = e.id.clone(); + } + let b = ui.small_button(tr("ui.ai_test")); + crate::scenario::publish_handle(&format!("prefs:ai_test:{}", e.id), b.rect.center()); + if b.on_hover_text(tr(if e.kind == Kind::Acp { "ui.ai_test_acp_tip" } else { "ui.ai_test_api_tip" })).clicked() { + test = Some(e.clone()); + } + // Its own permission mode, or the global one. + let cur = app.preferences.agent_permissions.get(&e.id).copied(); + let shown = cur.map_or_else(|| tr("ui.ai_mode_global").to_string(), |m| m.label().to_string()); + let mut pick: Option> = None; + egui::ComboBox::from_id_salt(("sc_ai_mode", &e.id)).width(150.0).selected_text(shown).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), tr("ui.ai_mode_global")).clicked() { + pick = Some(None); + } + for m in Mode::ALL { + if ui.selectable_label(cur == Some(m), m.label()).clicked() { + pick = Some(Some(m)); + } + } + }); + match pick { + Some(Some(m)) => { + app.preferences.agent_permissions.insert(e.id.clone(), m); + } + Some(None) => { + app.preferences.agent_permissions.remove(&e.id); + } + None => {} + } + if !e.found && ui.small_button(tr("ui.ai_remove")).clicked() { + remove = Some(e.id.clone()); + } + }); + test_line(ui, &app.agent_chat.settings.tests.get(&e.id)); + }); + ui.add_space(3.0); + } + ui.horizontal(|ui| { + let b = ui.button(tr("ui.ai_add_agent")); + crate::scenario::publish_handle("prefs:ai_add", b.rect.center()); + if b.clicked() { + open_add(app); + } + if ui.add_enabled(app.agent_chat.detecting.is_none(), egui::Button::new(tr("ui.agent_chat_refresh"))).clicked() { + refresh(app, &ui.ctx().clone()); + } + }); + if let Some(e) = test { + let key = if e.key { app.services.secrets.as_ref().and_then(|s| s.get(&e.id).ok().flatten()) } else { None }; + let started = start_test(app, &e, key); + app.agent_chat.settings.tests.insert(e.id.clone(), started); + } + if let Some(id) = remove { + app.preferences.agents.retain(|a| a.id != id); + app.preferences.agent_permissions.remove(&id); + app.preferences.agent_always_allow.remove(&id); + if let Some(s) = &app.services.secrets { + let _ = s.delete(&id); + } + if app.agent_chat.agent_id.as_deref() == Some(id.as_str()) { + app.agent_chat.agent_id = None; + } + } + if app.agent_chat.settings.tests.values().any(|t| matches!(t, Test::Running(_))) { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); + } +} + +/// The Add agent dialog (drawn every frame while it is open). +pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { + if app.agent_chat.settings.add.is_none() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + let found: Vec = agents::entries(app).into_iter().filter(|e| e.found).collect(); + #[cfg(target_arch = "wasm32")] + let found: Vec = Vec::new(); + let has_keychain = app.services.secrets.is_some(); + let mut open = true; + let mut add = false; + let mut test = false; + let t = Tokens::get(); + egui::Window::new(tr("ui.ai_add_agent_title")) + .id(egui::Id::new("sc_ai_add")) + .collapsible(false) + .resizable(false) + .default_width(460.0) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .open(&mut open) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let Some(s) = app.agent_chat.settings.add.as_mut() else { return }; + if let Some(test) = s.test.as_mut() { + test.poll(); + } + ui.horizontal(|ui| { + for (k, label) in + [(AddKind::Installed, "ui.ai_add_installed"), (AddKind::Custom, "ui.ai_add_custom"), (AddKind::Api, "ui.ai_add_api")] + { + let r = ui.selectable_value(&mut s.kind, k, tr(label)); + crate::scenario::publish_handle(&format!("ai_add:{label}"), r.rect.center()); + if r.clicked() { + s.error = None; + s.test = None; + s.command.clear(); + s.name.clear(); + if k == AddKind::Api + && let Some((name, url, _)) = agents::API_PRESETS.get(s.preset) + { + s.name = name.to_string(); + s.base_url = url.to_string(); + } + } + } + }); + ui.separator(); + egui::Grid::new("sc_ai_add_grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| match s.kind { + AddKind::Installed => { + ui.label(tr("ui.agent_chat_pick")); + if found.is_empty() { + ui.label(RichText::new(tr("ui.agent_chat_none_found_short")).color(t.text_dim)); + } else { + let sel = found.get(s.installed).map(|e| e.name.clone()).unwrap_or_default(); + egui::ComboBox::from_id_salt("sc_ai_add_installed").width(260.0).selected_text(sel).show_ui(ui, |ui| { + for (i, e) in found.iter().enumerate() { + if ui.selectable_value(&mut s.installed, i, format!("{} {}", e.name, e.detail())).clicked() { + s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::>().join(" "); + s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix")); + } + } + }); + } + ui.end_row(); + if s.command.is_empty() + && let Some(e) = found.get(s.installed) + { + s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::>().join(" "); + s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix")); + } + ui.label(tr("ui.ai_name")); + ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.agent_chat_command")); + ui.add(egui::TextEdit::singleline(&mut s.command).desired_width(300.0)); + ui.end_row(); + ui.label(""); + ui.add(egui::Label::new(RichText::new(tr("ui.ai_add_installed_note")).size(11.0).color(t.text_dim)).wrap()); + ui.end_row(); + } + AddKind::Custom => { + ui.label(tr("ui.ai_name")); + ui.add(egui::TextEdit::singleline(&mut s.name).hint_text("My agent").desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.agent_chat_command")); + ui.add(egui::TextEdit::singleline(&mut s.command).hint_text("my-agent --acp").desired_width(300.0)); + ui.end_row(); + ui.label(""); + ui.add(egui::Label::new(RichText::new(tr("ui.agent_chat_custom_hint")).size(11.0).color(t.text_dim)).wrap()); + ui.end_row(); + } + AddKind::Api => { + ui.label(tr("ui.ai_preset")); + let presets: Vec = + agents::API_PRESETS.iter().map(|p| p.0.to_string()).chain(std::iter::once(tr("ui.ai_custom_url").to_string())).collect(); + let sel = presets.get(s.preset).cloned().unwrap_or_default(); + egui::ComboBox::from_id_salt("sc_ai_add_preset").width(200.0).selected_text(sel).show_ui(ui, |ui| { + for (i, p) in presets.iter().enumerate() { + if ui.selectable_value(&mut s.preset, i, p).clicked() { + if let Some((name, url, _)) = agents::API_PRESETS.get(i) { + s.base_url = url.to_string(); + s.name = name.to_string(); + } else { + s.base_url.clear(); + } + } + } + }); + ui.end_row(); + ui.label(tr("ui.ai_name")); + ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.ai_base_url")); + ui.add(egui::TextEdit::singleline(&mut s.base_url).hint_text("https://example.com/v1").desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.ai_model")); + ui.add(egui::TextEdit::singleline(&mut s.model).hint_text("qwen3:14b").desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.ai_key")); + ui.add_enabled( + has_keychain, + egui::TextEdit::singleline(&mut s.key).password(true).hint_text(tr("ui.ai_key_hint")).desired_width(300.0), + ); + ui.end_row(); + ui.label(""); + let note = if has_keychain { tr("ui.ai_key_note") } else { tr("ui.ai_no_keychain") }; + ui.add(egui::Label::new(RichText::new(note).size(11.0).color(t.text_dim)).wrap()); + ui.end_row(); + ui.label(""); + ui.horizontal(|ui| { + if ui.button(tr("ui.ai_test")).clicked() { + test = true; + } + test_line(ui, &s.test.as_ref()); + }); + ui.end_row(); + } + }); + if let Some(e) = &s.error { + ui.label(RichText::new(e).color(t.error)); + } + ui.separator(); + ui.horizontal(|ui| { + let b = ui.add(egui::Button::new(tr("ui.ai_add")).fill(t.accent_soft)); + crate::scenario::publish_handle("ai_add:add", b.rect.center()); + if b.clicked() { + add = true; + } + }); + }); + if test { + #[cfg(not(target_arch = "wasm32"))] + if let Some(s) = app.agent_chat.settings.add.as_ref() { + let url = agents::normalize_url(&s.base_url); + let e = agents::Entry { + id: String::new(), + name: s.name.clone(), + kind: Kind::Api, + argv: Vec::new(), + version: None, + login: "", + found: false, + base_url: url.clone().unwrap_or_default(), + model: s.model.trim().to_string(), + key: false, + }; + let key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()); + let result = match url { + Ok(_) => start_test(app, &e, key), + Err(err) => Test::Done(Err(err)), + }; + if let Some(s) = app.agent_chat.settings.add.as_mut() { + s.test = Some(result); + } + } + } + if add { + match add_agent(app) { + Ok(()) => open = false, + Err(e) => { + if let Some(s) = app.agent_chat.settings.add.as_mut() { + s.error = Some(e); + } + } + } + } + if !open { + app.agent_chat.settings.add = None; + } + if app.agent_chat.settings.add.as_ref().is_some_and(|s| matches!(s.test, Some(Test::Running(_)))) { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } +} + +/// Add what the dialog holds; its key goes to the keychain. +fn add_agent(app: &mut SolveApp) -> Result<(), String> { + let Some(s) = app.agent_chat.settings.add.as_ref() else { return Ok(()) }; + let name = s.name.trim().chars().take(80).collect::(); + let mut c = AgentConfig { name, ..Default::default() }; + let mut key: Option = None; + match s.kind { + AddKind::Installed | AddKind::Custom => { + let cmd = s.command.trim(); + if acp::split_command(cmd).is_empty() { + return Err(tr("ui.ai_need_command").to_string()); + } + c.kind = Kind::Acp; + c.command = cmd.chars().take(4096).collect(); + c.id = agents::new_id(&app.preferences, "custom"); + } + AddKind::Api => { + c.kind = Kind::Api; + c.base_url = agents::normalize_url(&s.base_url)?; + c.model = s.model.trim().to_string(); + if c.model.is_empty() { + return Err(tr("ui.ai_need_model").to_string()); + } + key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()); + if key.is_some() && !agents::key_safe_for(&c.base_url) { + return Err(tr("ui.ai_key_needs_https").to_string()); + } + c.id = agents::new_id(&app.preferences, "api"); + } + } + if c.name.is_empty() { + c.name = c.id.clone(); + } + if let Some(k) = key { + let Some(store) = &app.services.secrets else { return Err(tr("ui.ai_no_keychain").to_string()) }; + store.set(&c.id, &k).map_err(|e| trf("ui.ai_keychain_error", &[("error", &e)]))?; + c.key = true; + } + app.preferences.agents.push(c); + Ok(()) +} diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs new file mode 100644 index 00000000..a2208cc1 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -0,0 +1,305 @@ +use super::*; +use std::sync::mpsc::channel; + +fn chat() -> AgentChat { + let mut c = AgentChat::default(); + c.state = Some(State::Ready); + c +} + +#[test] +fn streamed_text_joins_and_tool_updates_change_their_call() { + let mut c = chat(); + for e in [ + Event::Text { thought: false, text: "Mak".into() }, + Event::Text { thought: false, text: "ing a box".into() }, + Event::Tool { id: "t".into(), title: Some("batch".into()), kind: Some("other".into()), status: Some(ToolStatus::Pending), output: None }, + Event::Tool { id: "t".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) }, + ] { + c.on_event(e); + } + assert_eq!(c.items.len(), 2); + assert!(matches!(&c.items[0], Item::Agent { text, .. } if text == "Making a box")); + assert!(matches!(&c.items[1], Item::Tool { title, status: Some(ToolStatus::Completed), output: Some(o), .. } if title == "batch" && o == "ok")); +} + +#[test] +fn a_sign_in_request_keeps_the_question_for_after_the_login() { + let mut c = chat(); + c.last = Some(("make a cube".into(), false)); + c.state = Some(State::Busy); + assert!(c.on_event(Event::TurnEnded { reason: "auth".into() }).is_some()); + c.on_event(Event::AuthRequired { methods: vec![] }); + assert_eq!(c.state(), State::NeedsAuth); + assert_eq!(c.queued, Some(("make a cube".into(), false))); +} + +#[test] +fn a_plan_update_replaces_the_turns_plan() { + let mut c = chat(); + c.push(Item::User { text: "go".into(), image: false }); + c.on_event(Event::Plan(vec![("a".into(), "pending".into())])); + c.on_event(Event::Text { thought: false, text: "working".into() }); + c.on_event(Event::Plan(vec![("a".into(), "completed".into())])); + let plans: Vec<_> = c.items.iter().filter(|i| matches!(i, Item::Plan(_))).collect(); + assert_eq!(plans.len(), 1); + assert!(matches!(plans[0], Item::Plan(p) if p[0].1 == "completed")); +} + +#[test] +fn a_permission_is_answered_once() { + let mut c = chat(); + let opts = vec![PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }]; + c.push(Item::Permission { key: 5, title: "batch".into(), options: opts, answer: None }); + assert_eq!(c.waiting(), 1); + c.answer(0, Some("allow")); + c.answer(0, None); + assert!(matches!(&c.items[0], Item::Permission { answer: Some(a), .. } if a == "Allow")); + assert_eq!(c.waiting(), 0); +} + +/// An app with the chat's endpoint wired to a channel the test feeds, and a message in progress. +fn app_in_a_turn() -> (SolveApp, Sender) { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + // The default mode: modelling runs, files ask. + assert_eq!(app.preferences.ai_permissions, agents::Mode::Edits); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + app.agent_chat.state = Some(State::Busy); + start_turn(&mut app, "two boxes".into(), false); + (app, tx) +} + +fn ask(tx: &Sender, method: &str, params: Value) -> Receiver { + let (req, rx) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + rx +} + +fn frame(app: &mut SolveApp) { + let ctx = egui::Context::default(); + poll(app, &ctx); + app.drain_control(&ctx); +} + +fn boxed(x: f64) -> Value { + json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}}) +} + +/// Everything the agent changed in one message undoes as one step, and the conversation offers +/// that undo while nothing else has happened since. +#[test] +fn a_messages_changes_undo_as_one_step() { + let (mut app, tx) = app_in_a_turn(); + let before = app.session.doc.clone(); + let depth = app.session.undo.len(); + let replies: Vec<_> = [0.0, 20.0, 40.0].into_iter().map(|x| ask(&tx, "engine.execute", boxed(x))).collect(); + frame(&mut app); + for r in replies { + assert_eq!(r.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + } + assert_eq!(app.session.undo.len(), depth + 3); + assert_eq!(app.agent_chat.turn.as_ref().map(|t| t.changes), Some(3)); + app.agent_chat.on_event(Event::TurnEnded { reason: String::new() }); + end_turn(&mut app); + assert_eq!(app.session.undo.len(), depth + 1, "one step"); + assert!(app.session.undo.last().is_some_and(|s| s.label.contains("two boxes"))); + let n = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 3, .. })).expect("the changes are listed"); + undo_changes(&mut app, n); + assert!(Arc::ptr_eq(&app.session.doc, &before), "back to the design before the message"); + assert!(matches!(app.agent_chat.items[n], Item::Changes { undone: true, .. })); +} + +/// Saving waits for Allow or Deny; Deny answers the agent without running it, Allow runs it. +#[test] +fn file_actions_wait_for_the_user() { + let (mut app, tx) = app_in_a_turn(); + let dir = std::env::temp_dir().join(format!("sc-agent-chat-approve-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.solidcraft"); + let save = json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]}); + let denied = ask(&tx, "engine.execute", save.clone()); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 1); + assert!(denied.try_recv().is_err(), "nothing answered before the user decides"); + let id = app.agent_chat.held[0].id; + approve(&mut app, id, false); + let r = denied.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + assert!(r["error"].as_str().is_some_and(|e| e.contains("declined")), "{r}"); + assert!(!path.exists()); + let allowed = ask(&tx, "engine.execute", save); + frame(&mut app); + let id = app.agent_chat.held[0].id; + approve(&mut app, id, true); + frame(&mut app); + let r = allowed.recv_timeout(std::time::Duration::from_secs(30)).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Crash reports and app methods never run for the agent, requests outside a message don't +/// either, and a message stops taking changes at the limit. +#[test] +fn the_gate_refuses_and_limits() { + let (mut app, tx) = app_in_a_turn(); + app.preferences.agent_chat_turn_limit = 2; + let crash = ask(&tx, "engine.execute", json!({"command": "diagnostics.crash_reports", "params": {}})); + let quit = ask(&tx, "app.quit", json!({"force": true})); + let ok: Vec<_> = (0..2).map(|i| ask(&tx, "engine.execute", boxed(f64::from(i) * 20.0))).collect(); + let over = ask(&tx, "engine.execute", boxed(80.0)); + let read = ask(&tx, "document.inspect", json!({})); + frame(&mut app); + let get = |r: Receiver| r.recv_timeout(std::time::Duration::from_secs(30)).unwrap(); + assert!(get(crash)["error"].as_str().is_some_and(|e| e.contains("crash reports"))); + assert!(get(quit)["error"].as_str().is_some_and(|e| e.contains("not available"))); + for r in ok { + assert_eq!(get(r)["ok"], true); + } + assert!(get(over)["error"].as_str().is_some_and(|e| e.contains("limit of 2"))); + assert_eq!(get(read)["ok"], true, "reads go on past the limit"); + end_turn(&mut app); + let late = ask(&tx, "engine.execute", boxed(100.0)); + frame(&mut app); + assert!(get(late)["error"].as_str().is_some_and(|e| e.contains("no message in progress"))); +} + +#[test] +fn merging_finds_the_turns_start_when_the_history_was_trimmed() { + // Distinct designs: what counts is which `Arc` a snapshot holds. + let doc = |_: usize| Arc::new(solidcraft_engine::doc::Document::default()); + let snap = |d: &Arc| solidcraft_engine::Snapshot { + label: String::new(), + doc: d.clone(), + active_sketch: None, + pending_moves: Default::default(), + }; + let docs: Vec<_> = (0..6).map(doc).collect(); + // The turn began at depth 3 with docs[3]; the history then lost its oldest step. + let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect(); + assert_eq!(merge_undo(&mut undo, &docs[3], 3, "Agent"), 3); + assert_eq!(undo.len(), 3); + assert!(Arc::ptr_eq(&undo[2].doc, &docs[3]) && undo[2].label == "Agent"); + // A start that is no longer in the history: nothing is merged. + let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect(); + assert_eq!(merge_undo(&mut undo, &doc(9), 3, "Agent"), 0); + assert_eq!(undo.len(), 5); +} + +/// People who don't want AI never see it: with "Show AI features" off there is no button, the +/// shortcut does nothing, no panel draws, nothing is looked for and nothing starts, even when +/// asked over the control channel. Turned on, the button is there; discovery waits for the panel. +#[test] +fn with_ai_features_off_nothing_of_the_chat_shows_or_runs() { + use crate::scenario::{handle_at, rect_of}; + let mut h = crate::scenario::Harness::new(); + let shortcut = json!({"key": "A", "shift": true, "cmd": true}); + h.app.preferences.ai_features = false; + h.frames(3); + assert!(handle_at("toolbar:ai").is_none(), "no toolbar button"); + h.call("ui.key", shortcut.clone()); + h.frames(3); + h.call("ui.agent_chat", json!({"open": true, "send": "hello", "connect": true})); + h.frames(3); + assert!(!h.app.agent_chat.open && rect_of("agent_chat:panel").is_none(), "no panel"); + assert!(h.app.agent_chat.detection.is_none() && h.app.agent_chat.detecting.is_none(), "nothing looked for"); + assert!(h.app.agent_chat.backend.is_none() && h.app.agent_chat.tools_port.is_none(), "nothing started"); + // The View settings menu has no chat entry either. + if let Some(view) = handle_at("nav:view") { + h.call("ui.click", json!({"x": view.x, "y": view.y})); + h.frames(3); + assert!(handle_at("view:agent_chat").is_none(), "no menu entry"); + h.call("ui.click", json!({"x": view.x, "y": view.y})); + } + // On (the default): the button shows; discovery waits until the panel opens. + h.app.preferences.ai_features = true; + h.frames(3); + assert!(handle_at("toolbar:ai").is_some(), "the toolbar button"); + assert!(h.app.agent_chat.detection.is_none() && h.app.agent_chat.detecting.is_none(), "nothing looked for yet"); + h.call("ui.key", shortcut.clone()); + h.frames(3); + assert!(h.app.agent_chat.open && rect_of("agent_chat:panel").is_some(), "Ctrl+Shift+A opens the panel"); + assert!(h.app.agent_chat.detection.is_some() || h.app.agent_chat.detecting.is_some(), "the panel looks for agents"); + assert!(h.app.agent_chat.backend.is_none(), "but starts none until a chat begins"); + // Without the toolbar button the shortcut still works. + h.app.preferences.ai_toolbar_button = false; + h.call("ui.key", shortcut); + h.frames(3); + assert!(handle_at("toolbar:ai").is_none() && !h.app.agent_chat.open); +} + +#[test] +fn ai_features_and_the_toolbar_button_are_on_by_default() { + let p = crate::prefs::Prefs::default(); + assert!(p.ai_features && p.ai_toolbar_button); +} + +/// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command +/// through from then on for this agent. "Allow everything": even a save runs without a question. +#[test] +fn permission_modes_decide_what_asks() { + let (mut app, tx) = app_in_a_turn(); + app.agent_chat.running_id = "claude".into(); + app.preferences.ai_permissions = agents::Mode::Ask; + let first = ask(&tx, "engine.execute", boxed(0.0)); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 1, "a modelling command asks"); + let id = app.agent_chat.held[0].id; + approve_always(&mut app, id); + frame(&mut app); + assert_eq!(first.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + assert!(agents::always_allowed(&app.preferences, "claude", "solid.box")); + let second = ask(&tx, "engine.execute", boxed(20.0)); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 0, "remembered"); + assert_eq!(second.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + // Another agent keeps asking. + app.agent_chat.running_id = "codex".into(); + let _third = ask(&tx, "engine.execute", boxed(40.0)); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 1); + decline_all(&mut app, "test"); + // Allow everything, set for this agent only. + app.preferences.agent_permissions.insert("codex".into(), agents::Mode::All); + let dir = std::env::temp_dir().join(format!("sc-agent-chat-all-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("b.solidcraft"); + let save = ask( + &tx, + "engine.execute", + json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]}), + ); + frame(&mut app); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 0); + assert_eq!(save.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + // Crash reports stay off limits whatever the mode. + let crash = ask(&tx, "engine.execute", json!({"command": "diagnostics.crash_reports"})); + frame(&mut app); + assert_eq!(crash.recv_timeout(std::time::Duration::from_secs(5)).unwrap()["ok"], false); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The agent's own questions: SolidCraft's tools are allowed (its gate checks them), an earlier +/// "Always allow" answers by itself, and both leave a note; anything else is asked. +#[test] +fn the_agents_own_questions_are_answered_by_the_mode_or_asked() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.agent_chat.running_id = "claude".into(); + let opts = || { + vec![ + PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }, + PermissionOption { id: "always".into(), name: "Always".into(), kind: "allow_always".into() }, + PermissionOption { id: "no".into(), name: "Reject".into(), kind: "reject_once".into() }, + ] + }; + on_permission(&mut app, 1, "mcp__solidcraft__batch".into(), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { text, .. }) if text.contains("mcp__solidcraft__batch"))); + on_permission(&mut app, 2, "Run ls".into(), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Permission { answer: None, .. }))); + let n = app.agent_chat.items.len() - 1; + answer_permission(&mut app, n, Some("always")); + assert!(agents::always_allowed(&app.preferences, "claude", "Run ls")); + on_permission(&mut app, 3, "Run ls".into(), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e423f59b..bf3ced9a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -172,6 +172,21 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result Option> { + let guides = + solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }; + let scene = solidcraft_engine::view::scene_with_palette_and_guides( + &app.session, + &app.cam, + crate::viewport::section_plane(app), + solidcraft_engine::view::world_curve_parts, + &crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision), + guides, + ); + render_png(&scene, &app.cam, w, h) +} + pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let scope = match request_scope(app, &req.params) { Ok(Some(scope)) => scope, @@ -641,6 +656,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})), }, "ui.prefs" => wrap(crate::prefs::control(app, p)), + "ui.agent_chat" => wrap(crate::agent_chat::control(app, ctx, p)), "ui.help" => match s("item") { Some(i @ ("about" | "shortcuts" | "report" | "crashes")) => { crate::help::run(app, ctx, i); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index ef81b5ad..0b2652e2 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -14,7 +14,7 @@ use crate::theme::Tokens; /// Keys the application handles before any command shortcut. pub const RESERVED: &[&str] = - &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"]; + &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma", "Ctrl+Shift+A"]; #[derive(Default)] pub struct Keymap { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c8e3aaa4..de50cb2c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] pub mod access; +pub mod agent_chat; pub mod agent_cursor; pub mod browser; pub mod canvas; @@ -249,6 +250,10 @@ pub struct Services { pub http_download: Option, /// The browser's offer to install the web app (`install`); None: no Install button (desktop). pub install_app: Option, + /// HTTP for the agent chat's API agents (OpenAI-compatible endpoints); None: no API agents. + pub agent_http: Option, + /// The system keychain, for API agents' keys; None: keys can't be stored. + pub secrets: Option>, } pub struct SolveApp { @@ -350,6 +355,8 @@ pub struct SolveApp { control_held: std::collections::VecDeque, /// The agent cursor acting out control requests. pub agent: agent_cursor::AgentCursor, + /// The agent chat panel (docs/design/in-app-agent-chat.md). + pub agent_chat: agent_chat::AgentChat, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, queued_shots: Vec<(u64, f64, u32)>, shot_token: u64, @@ -432,6 +439,7 @@ impl SolveApp { synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), control_rx: None, + agent_chat: Default::default(), control_held: Default::default(), agent: Default::default(), pending_shots: Vec::new(), @@ -1309,6 +1317,7 @@ impl SolveApp { if render_workspace::active(self) { render_workspace::panel(self, ui); } + agent_chat::show(self, ui); preview::update(self, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { if render_workspace::active(self) { @@ -1342,7 +1351,12 @@ impl SolveApp { /// Held requests beyond this are refused rather than kept. const MAX_HELD: usize = 256; - let Some(rx) = self.control_rx.take() else { return }; + // Held requests (and the agent chat's, which it hands over here) run even when the app + // has no control channel of its own. + if self.control_rx.is_none() && self.control_held.is_empty() { + return; + } + let rx = self.control_rx.take(); // While the agent cursor acts out a request, later ones wait their turn. for _ in 0..PER_FRAME { if self.agent.busy() { @@ -1350,7 +1364,7 @@ impl SolveApp { } // Held requests go first, in order, once the file is in. let held = if self.file_load.busy() || self.file_picker.is_some() { None } else { self.control_held.pop_front() }; - let Some(req) = held.or_else(|| rx.try_recv().ok()) else { break }; + let Some(req) = held.or_else(|| rx.as_ref().and_then(|rx| rx.try_recv().ok())) else { break }; // A file is loading: commands that change the design wait for it (the window, // inspection and screenshots still answer). if ((self.file_load.busy() || self.file_picker.is_some()) @@ -1387,7 +1401,7 @@ impl SolveApp { } ctx.request_repaint(); } - self.control_rx = Some(rx); + self.control_rx = rx; } fn issue_screenshots(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index c28d63e7..9722bc36 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -206,7 +206,8 @@ pub fn nav_preset(id: &str) -> &'static NavPreset { NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0]) } /// The dialog's pages, in order. -pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"]; +pub const PAGES: [&str; 8] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection", "AI"]; +pub const PAGE_AI: usize = 7; pub const PAGE_SHORTCUTS: usize = 2; pub const PAGE_DESIGN: usize = 3; pub const PAGE_GRAPHICS: usize = 4; @@ -325,6 +326,27 @@ pub struct Prefs { pub save_mesh_send: bool, pub save_mesh_slicer: String, pub custom_slicers: Vec, + /// AI features (docs/agent-chat.md): the master switch. Off: no AI button, shortcut, menu + /// entry or panel, and nothing is looked for or started. + pub ai_features: bool, + /// The AI button at the toolbar row's right end (the shortcut and View menu entry stay). + pub ai_toolbar_button: bool, + /// Agents the user added (the installed ones are found by themselves). API keys are never + /// here: they live in the system keychain. + pub agents: Vec, + /// The agent a new conversation starts with (an agent's id; empty: the first one available). + pub agent_default: String, + /// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent. + pub ai_permissions: crate::agent_chat::agents::Mode, + pub agent_permissions: std::collections::BTreeMap, + /// "Always allow" answers: per agent, the tools it may use without asking. + pub agent_always_allow: std::collections::BTreeMap>, + /// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip. + pub agent_chat_side: String, + pub agent_chat_width: f32, + pub agent_chat_minimized: bool, + /// Changes the agent may make for one message. + pub agent_chat_turn_limit: u32, } /// What a middle-button double-click in the view does. @@ -394,6 +416,17 @@ impl Default for Prefs { save_mesh_send: false, save_mesh_slicer: String::new(), custom_slicers: Vec::new(), + ai_features: true, + ai_toolbar_button: true, + agents: Vec::new(), + agent_default: String::new(), + ai_permissions: crate::agent_chat::agents::Mode::Edits, + agent_permissions: Default::default(), + agent_always_allow: Default::default(), + agent_chat_side: "right".into(), + agent_chat_width: 380.0, + agent_chat_minimized: false, + agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT, } } } @@ -492,6 +525,17 @@ fn defaults(app: &mut SolveApp, page: usize) { app.autosave_minutes = 5.0; set_autosave(app); } + PAGE_AI => { + // The agents the user added and their "always allow" answers stay. + p.ai_features = d.ai_features; + p.ai_toolbar_button = d.ai_toolbar_button; + p.ai_permissions = d.ai_permissions; + p.agent_permissions = d.agent_permissions; + p.agent_chat_side = d.agent_chat_side; + p.agent_chat_width = d.agent_chat_width; + p.agent_chat_minimized = d.agent_chat_minimized; + p.agent_chat_turn_limit = d.agent_chat_turn_limit; + } 1 => { p.nav = d.nav; p.navigator_style = d.navigator_style; @@ -925,6 +969,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { crate::keymap::page(app, ui); return; } + if app.prefs_window.section == PAGE_AI { + crate::agent_chat::settings::page(app, ui); + return; + } egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section { 0 => { ui.label(app.tr("ui.language")); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 289ff5b9..b6a86f3d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -650,7 +650,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if tab == "SKETCH" && panel == "CREATE" { (count.max(1) as f32 * 68.0).max(64.0) + 8.0 } else { panel_width(count) } }; let widths: Vec = lists.iter().map(|(panel, _, shown)| width_of(panel, shown.len())).collect(); - let fits = fit(&widths, r.right() - 40.0 - px); + // The AI button keeps the toolbar row's right end, whatever the ribbon's width + // (docs/agent-chat.md); panels that no longer fit go to the » menu as usual. + let right = if crate::agent_chat::toolbar_button_shown(app) { r.right() - AI_BUTTON_ROOM } else { r.right() }; + let fits = fit(&widths, right - 40.0 - px); crate::scenario::publish_count("toolbar_collapsed", fits.iter().filter(|f| **f == Fit::Collapsed).count() as f64); crate::scenario::publish_count("toolbar_more", fits.iter().filter(|f| **f == Fit::More).count() as f64); for ((panel, cmds, shown), fit) in lists.into_iter().zip(fits) { @@ -910,7 +913,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { px += 2.0; } if !overflow.is_empty() { - let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0)); + let at = Rect::from_min_size(pos2(px.min(right - 40.0), top + 4.0), vec2(38.0, 52.0)); ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| { let response = ui.menu_button("»", |ui| { for (panel, cmds) in &overflow { @@ -938,6 +941,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { response.response.on_hover_text(crate::i18n::tr_label("More toolbar panels")); }); } + if right < r.right() { + ai_button(app, ui, Rect::from_min_max(pos2(right + 4.0, r.top() + 4.0), pos2(r.right() - 6.0, r.bottom() - 5.0))); + } painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); // Dragging a command: its icon follows the pointer; a toolbar button dragged off the // toolbar (or into the open drop-down) shows it will be removed. @@ -967,6 +973,40 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { }); } +/// Room the AI button takes at the toolbar row's right end. +const AI_BUTTON_ROOM: f32 = 52.0; + +/// The AI button: shows and hides the agent chat panel (lit while it is open). +fn ai_button(app: &mut SolveApp, ui: &mut egui::Ui, at: Rect) { + let t = Tokens::get(); + let resp = ui.interact(at, ui.id().with("ai_button"), Sense::click()); + crate::scenario::publish_handle("toolbar:ai", at.center()); + let open = app.agent_chat.open; + let painter = ui.painter(); + painter.line_segment([pos2(at.left() - 4.0, at.top() + 4.0), pos2(at.left() - 4.0, at.bottom() - 4.0)], Stroke::new(1.0, t.border)); + if open || resp.hovered() { + painter.rect_filled(at, 4.0, if open { t.accent_soft } else { t.hover }); + } + icons::paint( + painter, + Rect::from_center_size(pos2(at.center().x, at.top() + 24.0), vec2(26.0, 26.0)), + "agent_chat", + t.icon, + t.icon_fill, + t.accent, + ); + painter.text(pos2(at.center().x, at.top() + 52.0), Align2::CENTER_CENTER, crate::i18n::tr("ui.ai_button"), FontId::proportional(11.0), t.text); + let waiting = app.agent_chat.waiting(); + if waiting > 0 { + let c = pos2(at.right() - 6.0, at.top() + 9.0); + painter.circle_filled(c, 6.0, t.warning); + painter.text(c, Align2::CENTER_CENTER, waiting.min(9).to_string(), FontId::proportional(9.5), t.toolbar); + } + if resp.on_hover_text(crate::agent_chat::panel_tip()).clicked() { + crate::agent_chat::toggle(app); + } +} + /// What a click on a panel drop-down row asks for. enum RowAction { None, diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index ddea6402..ec9fde46 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -3280,6 +3280,17 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { }); ui.checkbox(&mut s.follow_camera, crate::i18n::tr_label("Follow camera (Look At sketches)")); }); + // The agent chat panel, once the chat is turned on in Preferences. + if crate::agent_chat::shown(app) { + ui.separator(); + let mut open = app.agent_chat.open; + let r = ui.checkbox(&mut open, crate::agent_chat::panel_tip()); + crate::scenario::publish_handle("view:agent_chat", r.rect.center()); + if r.changed() { + app.agent_chat.open = open; + app.preferences.agent_chat_minimized &= !open; + } + } }); }); let clicked_out = ui.input(|i| i.pointer.any_pressed()) diff --git a/docs/agent-chat.md b/docs/agent-chat.md new file mode 100644 index 00000000..185ba1a0 --- /dev/null +++ b/docs/agent-chat.md @@ -0,0 +1,181 @@ +# Agent chat + +Agent chat is a side panel where you talk to a coding agent you already use (Claude Code, +Codex, Gemini CLI, OpenCode and others), or to a model behind an OpenAI-compatible endpoint you +choose (Ollama or LM Studio on your computer, OpenAI, any other URL), and have it work on the +design that's open. A coding agent runs on your computer under **your own account** with that +agent: SolidCraft doesn't sell or proxy a model and needs no key for it. An endpoint's key, if +it needs one, goes into your system keychain, never into SolidCraft's settings. + +Desktop app only. + +## Turning it on and off + +Everything about it is on one page: **Preferences → AI**. + +- **Show AI features** (on by default) is the master switch. Turned off, SolidCraft shows nothing + of it: no AI button, no shortcut, no menu entry, no panel, and nothing is looked for or + started. The page stays, so you can turn it back on. +- **Show button in toolbar** (on by default): the **AI** button at the right end of the toolbar + row, which stays there however wide the window is. Turn it off to keep only the shortcut and + the menu entry. + +With AI features on, the panel opens from: + +- the **AI** button at the right end of the toolbar; +- **Ctrl+Shift+A** (Cmd+Shift+A on a Mac), which also works from the chat's own text box; +- **View settings** (the last button of the navigation bar under the view) → **Agent chat**. + +Nothing runs until you need it: SolidCraft looks for installed agents (a quick `--version` +each, in the background) when the panel or the AI page first opens, and starts an agent only +when you send a message or press **Connect**. + +## The panel + +- **Docking:** drag the panel's header (the dotted grip and the title) to the other half of the + window and let go. The side it would land on lights up while you drag. You can also pick + **Left** or **Right** under Preferences → AI → Panel. SolidCraft remembers the side. +- **Width:** drag the panel's inner edge. SolidCraft remembers it. +- **Minimise** (the line in the header) folds the panel into a thin strip that keeps the chat + icon, the agent's state, and a count of anything waiting for your answer. Click the icon to + unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does. +- **The agent switcher** under the title picks the agent for this conversation; switching stops + the one running. Next to it, when the agent has modes of its own (Claude Code has *Default*, + *Accept Edits*, *Plan*…), a second drop-down sets the agent's mode. + +## Agents + +Preferences → AI → **Agents** lists every agent the chat can use, each with its kind (a program or +an endpoint), version or model, whether it looks signed in, **Test**, **Default** (the one a new +conversation starts with), its own permission setting, and **Remove** for the ones you added. + +### Found by themselves + +SolidCraft looks for agents that speak the [Agent Client Protocol](https://agentclientprotocol.com) +on your `PATH` and in the usual install folders a desktop app's `PATH` often misses (npm's +global folder, nvm, Volta, bun, `~/.local/bin`, Homebrew, `%APPDATA%\npm`). For each one it +shows its **version** (from `--version`, given three seconds) and whether it looks **signed in**. +SolidCraft only checks whether the agent's own login file (`~/.claude/.credentials.json`, +`~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, …) or one of its key variables exists. It never +reads the file and never starts the agent to find out. On macOS Claude Code keeps its login in +the keychain, so SolidCraft doesn't guess there. + +| Agent | What to install | +|---|---| +| Claude Code | `npm install -g @agentclientprotocol/claude-agent-acp` (the ACP adapter for Claude Code) | +| Codex | `npm install -g @agentclientprotocol/codex-acp` | +| Gemini CLI | `npm install -g @google/gemini-cli` (it speaks ACP itself: `gemini --acp`) | +| OpenCode | `npm install -g opencode-ai` (`opencode acp`) | +| GitHub Copilot CLI, Qwen Code, goose | listed with their install command when missing | + +The panel lists agents that aren't installed with their install command and a **Copy** button. +**Refresh** looks again after you install one. **Test** starts an agent briefly, asks it for a +session and ends it again. + +### Add agent… + +- **Installed agent:** your own entry for an installed agent, for example with other arguments. +- **Custom command:** any other program that speaks ACP on stdin and stdout, such as + `my-agent --acp`. SolidCraft starts it directly, not through a shell. +- **OpenAI-compatible API:** a preset (Ollama `http://localhost:11434/v1`, LM Studio + `http://localhost:1234/v1`, OpenAI) or a custom URL, the model name, and an optional key. + The key goes into the system keychain (Keychain on macOS, Credential Manager on Windows, the + Secret Service on Linux) and is only ever sent over HTTPS or to this computer. **Test** asks the + endpoint for its models. For these agents SolidCraft runs the conversation itself, with the + same tools, limits and questions as for a coding agent, and at most 24 model requests per + message. + +## Signing in + +If a coding agent isn't signed in, it says so and the panel shows **Sign in to the agent** with +the agent's own ways to sign in: + +- **A command for a terminal**, e.g. `claude-agent-acp --cli auth login --claudeai`, with a **Copy** + button. Run it in a terminal, sign in there, then press **I've signed in: connect again**. +- **A button** for agents that sign in by themselves (Codex's ChatGPT login opens your browser). +- **A variable to set** for agents that take a key from the environment. The panel has no field + for a coding agent's key, on purpose. + +Whatever you typed before signing in is sent once the agent is ready. + +## What the agent can do + +The agent reaches the open design only through SolidCraft's own tools (the +[MCP server](mcp.md)), which SolidCraft hands it when the chat starts: + +- **Read the design:** list commands, inspect features, sketches and parameters, measure, + take pictures of the view. +- **Change the design:** run any modelling command, alone or in a batch. Changes show in the + window as they happen. +- **One undo per message:** everything the agent changes while answering one message becomes + a single undo step, named after your message. The conversation shows *N changes, one undo + step* with **Undo these changes** while nothing else has happened since. +- **Tool cards:** each tool call shows as a card with its state; click it to see what it returned. +- **Attach view:** tick it to send a picture of the current view with your message, for agents + that take pictures. + +## Permissions + +Preferences → AI → **Permissions** decides what the agent may do without asking, for every agent; +each agent can have its own setting in its row. + +- **Ask every time:** every change to the design and every file action waits for **Allow**, + **Always allow** or **Deny** in the panel. +- **Auto-allow model edits** (the default): modelling commands run at once; saving, opening, exporting, + importing, starting a new design, any command given a file name, and undoing past the start of + your message still ask. +- **Allow everything:** nothing asks. The page warns you: the agent can then save, overwrite and + replace your design. + +**Always allow** remembers that command for that agent. The page lists what you allowed this way, +per agent, with **Reset**. + +These settings cover SolidCraft's tools. A coding agent's own tools (its shell, its file edits) +follow the agent's own mode, which you pick in the panel's header. When an agent asks before +using one of SolidCraft's tools, SolidCraft answers for you, since its own gate checks the call, +and notes it in the chat as *Auto-allowed*. With **Allow everything**, or after **Always allow**, +the agent's other questions are answered the same way and noted too. + +## What it can't do + +- **Files wait for you** unless you chose **Allow everything**. **Cancel** or **Stop** declines + everything that's waiting, and a question nobody answers is declined after four minutes. The + [MCP server's folder limits](mcp.md) still apply after you allow: the design's folder, the + recovery folder and the server's own scratch folder. +- **A limit per message.** The agent may make 24 changes per message by default; you can set + the number on the AI page. Reading the design doesn't count. When it reaches the limit, it's + told to stop and report what's left; send another message to let it carry on. +- **No crash reports,** whatever the permissions. Crash reports can hold other designs and file + paths. +- **Not the rest of the app.** It can't click the interface, change settings, quit, or drive the + chat itself. +- **Its own tools stay off where we can turn them off.** SolidCraft declares no file system and + no terminal to the agent, runs it in an empty working folder of its own, and asks the Claude + Code adapter to turn off its shell, file and web tools for the session. + +When the panel's agent is stopped, another agent is picked, or SolidCraft quits, the agent and +every program it started (the CLI it wraps, its MCP server) are stopped. + +## Privacy + +Nothing goes anywhere except to the agent you run, or the endpoint you added. SolidCraft starts +a coding agent as a program on your computer and talks to it over its standard input and +output. What the agent sends to its own service, under your account and that service's terms, +is between you and that service, exactly as when you use the agent in a terminal. SolidCraft +never sees, stores or passes on a coding agent's password, token or key. An endpoint's key is +kept in the system keychain and sent only to that endpoint. SolidCraft's own connection to the +agent's tools uses a random per-launch token in a file only you can read, which never passes +through the agent. + +## Trying it without an account + +A scripted test agent is in the source tree. It makes a block, rounds it and measures it, with +no model behind it: + +```sh +cargo build --release -p solidcraft-ui-egui --example acp_test_agent +``` + +Then add it under Preferences → AI → **Add agent… → Custom command** with the path of +`target/release/examples/acp_test_agent`. Put "save" in your message to see an Allow / Deny card, +or "ask" to see the agent ask its own permission. diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index f3b7f33b..45a6543a 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -17,6 +17,7 @@ - [Updates and privacy](guide/updates.md) - [Troubleshooting: SolidCraft won't start](guide/troubleshooting.md) - [Driving SolidCraft with an AI agent (MCP)](guide/mcp.md) +- [Agent chat](guide/agent-chat.md) # Developer guide diff --git a/docs/book/src/guide/agent-chat.md b/docs/book/src/guide/agent-chat.md new file mode 100644 index 00000000..4c114852 --- /dev/null +++ b/docs/book/src/guide/agent-chat.md @@ -0,0 +1 @@ +{{#include ../../../agent-chat.md}} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b700c340..be49d189 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -132,10 +132,11 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | +| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?`, `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) | | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | diff --git a/docs/design/in-app-agent-chat.md b/docs/design/in-app-agent-chat.md new file mode 100644 index 00000000..56f65a27 --- /dev/null +++ b/docs/design/in-app-agent-chat.md @@ -0,0 +1,216 @@ +# In-app agent chat over ACP + +Status: desktop only, behind Preferences → AI → *Show AI features* (on by default; off hides and +stops everything). The user guide is +[docs/agent-chat.md](../agent-chat.md). This note records what I found about the Agent Client +Protocol and the agents that speak it, how the chat is built, and what's still open. + +## Goal + +A chat panel inside SolidCraft that drives a coding agent **the user already has and is already +signed in to** (Claude Code, Codex, Gemini CLI, …). SolidCraft holds no API keys and no +accounts: the agent runs as a child process under the user's own login, and SolidCraft gives +it one thing, its MCP server bridged to the open window, so the agent models on the design the +user is looking at. + +## The protocol + +[ACP](https://agentclientprotocol.com) is JSON-RPC 2.0 over the agent's stdin/stdout, one +message per line, protocol version `1`. The editor (here SolidCraft) is the *client*; the agent +is the *server*, started as a subprocess. Zed introduced it to embed Claude Code and Codex; +the Rust SDK (`agent-client-protocol`, Apache-2.0) and the schema crate +(`agent-client-protocol-schema`, Apache-2.0) are the reference. + +What the prototype uses: + +| Direction | Message | Use | +|---|---|---| +| → agent | `initialize {protocolVersion: 1, clientCapabilities, clientInfo}` | We declare no file system and no terminal (`fs.readTextFile/writeTextFile: false`, `terminal: false`), and terminal login (`auth.terminal: true`, plus the older `_meta["terminal-auth"]`). The reply carries `agentInfo` and `authMethods`. | +| → agent | `session/new {cwd, mcpServers}` | `mcpServers` holds one stdio server: `solidcraft-cli mcp --connect 127.0.0.1:PORT`. `cwd` is the open design's folder (else SolidCraft's own `agent` folder). | +| → agent | `session/prompt {sessionId, prompt: [content]}` | One text block. The reply comes at the end of the turn with `stopReason` (`end_turn`, `cancelled`, `refusal`, …). | +| → agent | `session/cancel {sessionId}` (notification) | Cancel button. Open permission prompts are answered `cancelled` first, as the spec asks. | +| → agent | `authenticate {methodId}` | Only for an "agent" login method the user clicks (the agent then opens the browser itself). | +| ← agent | `session/update` | `agent_message_chunk`, `agent_thought_chunk` (streamed text), `tool_call` / `tool_call_update` (`toolCallId`, `title`, `kind`, `status`: pending, in_progress, completed, failed, `content`), `plan` (entries with status). Mode, command and usage updates are ignored for now. | +| ← agent | `session/request_permission {toolCall, options: [{optionId, name, kind}]}` | Shown as a prompt in the conversation; the answer is `{outcome: {outcome: "selected", optionId}}` or `{outcome: "cancelled"}`. `kind` is `allow_once`, `allow_always`, `reject_once` or `reject_always`. | +| ← agent | `fs/*`, `terminal/*` | Not offered, answered "method not found". | +| ← agent | `_auth/status_update` | An extension both current adapters send ("Not logged in"); shown next to the agent's name. | + +Errors: an agent that is not signed in answers `session/new` (Codex) or the first +`session/prompt` (Claude) with JSON-RPC error `-32000 "Authentication required"`. + +## The agents + +All checked on 2026-10-14 with the packages installed into a user prefix +(`npm i -g --prefix ~/…`). Licences are from each package's own metadata; nothing is bundled +with SolidCraft, the user installs the agent. + +| Agent | How it speaks ACP | Package / licence | Version probe | Login | +|---|---|---|---|---| +| Claude Code | `claude-agent-acp` adapter (renamed from `@zed-industries/claude-code-acp`, which is now deprecated) | `@agentclientprotocol/claude-agent-acp` 0.90.0, Apache-2.0. It depends on `@anthropic-ai/claude-agent-sdk`, which is under Anthropic's own terms ("SEE LICENSE IN README.md"): fine for the user to install, not something we would vendor. | `--version` prints `0.90.0` | Terminal method. Offered only when the client declares terminal login. On a desktop: "Claude Subscription" = `claude-agent-acp --cli auth login --claudeai`, "Anthropic Console" = `… --cli auth login --console`. Over SSH (no browser) it offers `claude-login` = `… --cli` (the TUI `/login`). The login is Claude Code's own: `~/.claude/.credentials.json` (Linux, Windows) or the macOS keychain. | +| Codex | `codex-acp` adapter (`@zed-industries/codex-acp` 0.16 moved to `@agentclientprotocol/codex-acp`) | `@agentclientprotocol/codex-acp` 2.2.2, Apache-2.0 | `--version` (0.16 rejected the flag; 2.x prints it) | Agent method `chat-gpt` (the adapter opens the browser for the ChatGPT login when the client calls `authenticate`), and `api-key` for a key the user configures in Codex. 0.16 also listed `env_var` methods for `CODEX_API_KEY` / `OPENAI_API_KEY`. The login is Codex's own: `~/.codex/auth.json`. | +| Gemini CLI | built in: `gemini --acp` (the ACP registry's current flag; older releases used `--experimental-acp`) | `@google/gemini-cli` 0.64, Apache-2.0 | `--version` | Its own Google login (`~/.gemini/oauth_creds.json`) or a key in the environment. | +| OpenCode | built in: `opencode acp` | `opencode-ai` 1.19, MIT | `--version` | Its own providers' logins (`~/.local/share/opencode/auth.json`) or keys in the environment. | +| Others in the ACP registry | `copilot --acp`, `qwen --acp`, `opencode acp`, `goose acp`, `cline --acp`, `kimi acp`, … | mixed; several are proprietary, one is GPL and one AGPL (we only start them as programs, never link them) | `--version` | each its own | + +The registry (`https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json`) lists +about forty agents with their `npx`/binary distribution; a later version could read it to fill +the picker instead of the hard-coded table. + +### The auth story + +- **SolidCraft never sees a credential.** It doesn't pass API keys, doesn't store tokens and + doesn't open login pages by itself. Each agent keeps its own login where its CLI always does + (Claude Code: `~/.claude` or the keychain; Codex: `~/.codex`; Gemini: `~/.gemini`). A user + who already uses the agent in a terminal is already signed in, and the chat simply works. +- **Not signed in:** the agent answers "Authentication required". The panel switches to + *Sign-in needed* and lists the agent's own `authMethods`: + - *terminal* methods are shown as the exact command to run in a terminal (with Copy), e.g. + `claude-agent-acp --cli auth login --claudeai`; the user runs it, then presses Connect; + - *agent* methods (Codex's ChatGPT login) get a button that calls `authenticate`; the agent + opens the browser itself; + - *env_var* methods say which variable to set in the user's environment. The panel has no + field for a key, on purpose. +- Verified here, against both adapters, with an empty home folder (no credentials, so nobody's + account was used): Claude's adapter returns `-32000 Authentication required` on the first + prompt and offers the terminal login methods; Codex's returns it on `session/new` and offers + `chat-gpt` and `api-key`. I stopped at that step. + +## How it works + +``` +SolidCraft window ──(child process, own process group, ACP over stdio)──> agent (claude-agent-acp, codex-acp, gemini --acp, opencode acp, …) + ▲ │ starts, per session/new + │ the chat's own endpoint (loopback, fresh token, gate) ▼ + └──────────────────────────────────────────────────── solidcraft-cli mcp --connect 127.0.0.1:PORT +``` + +Code: `crates/ui-egui/src/agent_chat/`. + +| File | What | +|---|---| +| `backend.rs` | The `ChatBackend` trait and its `Event`s: the only thing the panel talks to. | +| `acp.rs`, `acp_client.rs` | The protocol messages, and `AcpBackend`, the first backend. | +| `api_client.rs` | `ApiBackend`: an OpenAI-compatible endpoint, with SolidCraft running the tool loop. | +| `agents.rs` | The agents (found and added), permission modes, "always allow" answers. | +| `settings.rs` | Preferences → AI and the Add agent dialog. | +| `process.rs` | The agent as a child process in a process group of its own; stopping it ends the group. | +| `discover.rs` | Which agents are installed, their versions, whether they look signed in. | +| `gate.rs` | What the agent's tool requests may do: run, wait for Allow/Deny, or never. | +| `mod.rs` | The conversation, turns (undo as one step, the per-turn limit), held requests. | +| `panel.rs` | The panel: docking, minimising, picker, sign-in, conversation, input. | + +### A pluggable backend + +The panel doesn't know which protocol it's talking. A backend implements `ChatBackend` (`poll` +for events, `prompt`, `cancel`, `answer_permission`, `authenticate`, `set_mode`, `shutdown`) and +gets a `StartContext` with its working folder, the MCP bridge and `tools` (a sender for control +requests, for a backend that runs tools in process). Two backends exist: + +- `AcpBackend` drives a coding agent the user already has (the default, and the point of the + feature: the user's own account, no keys). +- `ApiBackend` talks to an OpenAI-compatible chat completions endpoint (Ollama, LM Studio, + OpenAI, a custom URL) and runs the tool loop itself: it starts the same `solidcraft-cli mcp + --connect` server an ACP agent would get and is its MCP client (`tools/list` becomes the + function tools, `crash_reports` left out), so its calls pass the same gate, limits, questions + and undo as an ACP agent's. At most 24 model requests per message. The HTTP request is the + desktop host's (`Services::agent_http`, ureq with no redirects); the key comes from the system + keychain (`Services::secrets`, the keyring crate) for the connection only and is refused over + plain HTTP to another computer. + +Another kind of backend slots in the same way without reshaping the panel. + +### Design tools and the gate + +On the first Connect the app starts **an endpoint of the chat's own** +(`control_server::listen_agent`): a free loopback port with a fresh random token, written to the +per-user token file where `solidcraft-cli mcp --connect` finds it. The token never passes +through the agent, `SOLIDCRAFT_CONTROL_TOKEN` is removed from the agent's environment, and +`--allow-any-path` is never passed, so the MCP server keeps its #205 folder limits (a request +that names no folders is confined to the chat's working folder). + +Requests from that endpoint don't go to the app directly. `pump_tools` passes each one through +`gate::request`: + +- **Methods:** only `engine.commands`, `document.inspect`, `engine.execute`, `engine.batch`, + `ui.view`, `ui.render` and `ui.screenshot` (what the MCP server uses). No clicks, preferences, + quitting, scripts or the chat itself. +- **Refused:** `diagnostics.*` (crash reports hold other designs and paths), `app.*`. +- **Allow/Deny in the panel:** `file.*`, `doc.*`, render export, any command given a file or + folder, a picture saved outside the MCP server's scratch folder, and `edit.undo` past the + turn's first change. The request waits (the endpoint is patient) until the user answers; + Cancel/Stop declines everything waiting, and after four minutes it is declined (the MCP + server stops waiting at five). +- **Permission modes** (Preferences → AI, global with a per-agent override): *Ask every time* + turns every change into a question too; *Auto-allow model edits* (default) is the list above; + *Allow everything* runs what would ask (never what is refused). "Always allow" on a card + remembers the command per agent. The agent's own ACP permission questions about SolidCraft's + tools are answered "allow once" by the client (the gate decides), and others follow the mode + and the remembered answers; every automatic answer leaves an *Auto-allowed* note. +- **Per-turn limit:** 24 requests that run commands (a batch counts once; reads don't count), + set in Preferences, and 400 requests of any kind. Past it the agent is told to stop and say + what's left. +- **Outside a turn:** refused. + +What runs goes to the same queue as the control channel's held requests, so file loads, the +agent cursor and the per-request folder scope all apply as for any other client. + +### One undo step per message + +When a message is sent the chat remembers the design (`Arc`) and the undo depth. When +the turn ends, the undo snapshots made since are merged into the first one (which holds the +design from before the turn), relabelled "Agent chat: ". If the history was trimmed in +the meantime the start is found by the design it holds; if it's gone, nothing is merged. The +conversation shows the merged step with an Undo button while it's still the latest thing done. + +### The agent's own tools + +ACP lets the client offer a file system and a terminal; the chat offers neither, and starts the +agent in an empty working folder of SolidCraft's own (`/agent-chat/work`), not the +design's folder. The Claude Code adapter has shell, file and web tools of its own anyway: the +session asks it to turn them off (`_meta.claudeCode.options.disallowedTools`) and not to offer +"bypass permissions". Other agents' own permission requests appear in the panel with their +options. + +### Processes + +The agent starts in a process group of its own (Unix; on Windows `taskkill /T` ends its tree). +Stop, Close-and-Stop, turning the preference off, the agent chat being dropped and quitting all +end it the same way: its stdin closes (ACP agents end at end of input), then after a grace period +the group gets TERM, and KILL after another. Quitting waits for this (at most about three +seconds). `process.rs`'s tests start a fake agent that leaves helpers behind, once polite and +once ignoring both end of input and TERM, and check that none survive. If SolidCraft itself is +killed, the agent sees its stdin close and ends with its helpers. + +### Discovery + +As before, plus key variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, …) +reported as *key set*, `CLAUDE_CONFIG_DIR` and `CODEX_HOME` respected, and OpenCode's +`~/.local/share/opencode/auth.json`. Nothing is started except `--version`. + +### Panel + +Opened from the **AI** button at the toolbar row's right end (the panels give it room and spill +into » as usual; Preferences → AI → *Show button in toolbar* hides it), Ctrl+Shift+A and the View +settings menu. Docked left or right (dragging the header across the window, or Preferences), resizable, folded +to a 30 px strip with Minimise (state dot and a count of open questions), closed with ×. Side, +width, fold and the per-turn limit are preferences, not design data. Ctrl+Shift+A is reserved in the shortcut editor and works from the chat's text box too. The +header has the agent switcher (per conversation) and, for agents with ACP `modes`, the agent's +own mode picker (`session/set_mode`). + +### Control channel + +`ui.agent_chat` (docs/control-protocol.md) sets the preferences, opens and docks the panel, +connects, sends, answers permission and approval prompts, cancels, undoes the last message and +returns the whole state, for tests and screenshots. + +## Still open + +| Area | Work | +|---|---| +| Conversation | Markdown rendering, diffs, history per design, resume (`session/load`) | +| Session controls | Model and mode pickers from `configOptions` / `modes`, slash commands | +| Auth | An embedded terminal or "open a terminal here" for terminal logins on each OS; logout | +| Discovery | Read the ACP registry; keychain-aware login check on macOS | +| Packaging | Make sure every desktop package ships `solidcraft-cli` next to `solidcraft` (Flatpak, AppImage, macOS bundle, Windows zip), or serve MCP from the app binary itself | +| Windows | Job objects instead of `taskkill /T` once there's a safe API for them | +| API backend | Streaming answers; images in tool results |