From 6dffcb501ad0123282f143b18de648bd54aefbe2 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 00:13:06 +0100 Subject: [PATCH] Add terminal client, memory, model fallback, version and install improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codecraft: full-screen terminal client (ratatui) – local tools and files, models via the CodeCraft server gateway or own providers (standalone with a guided setup), slash commands, @files, permission dialogs, resume, update - Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the server: /cli/install.sh and /cli/download/ - Project memory (remember tool, .codecraft/memory.md) and personal memory - Model fallback when a host is unreachable or a budget is used up - Version/commit in UI and health, daily release check, "What's new" dialog - PWA: install button, maskable icon, manifest id and screenshots - Case-insensitive detection of connection errors for retries Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1081 ++++++++++++- Dockerfile | 23 + cli/Cargo.toml | 22 +- cli/src/agent.rs | 552 +++++++ cli/src/app.rs | 1632 ++++++++++++++++++++ cli/src/conf.rs | 102 ++ cli/src/editor.rs | 219 +++ cli/src/i18n.rs | 60 + cli/src/main.rs | 963 ++++-------- cli/src/models.rs | 152 ++ cli/src/palettes.json | 812 ++++++++++ cli/src/server.rs | 138 ++ cli/src/session.rs | 90 ++ cli/src/stats.rs | 36 + cli/src/theme.rs | 102 ++ cli/src/tools.rs | 568 +++++++ config.example.toml | 1 + core/src/config.rs | 3 + core/src/lib.rs | 1 + core/src/prompts.rs | 52 + docker-compose.yml | 6 +- server/resources-cli/install.sh | 46 + server/src/api.rs | 50 +- server/src/app.rs | 3 + server/src/auth.rs | 4 +- server/src/config.rs | 3 + server/src/main.rs | 2 + server/src/prompts.rs | 61 +- server/src/runner.rs | 28 +- server/src/tools/agent.rs | 39 + server/src/tools/mod.rs | 1 + server/src/update.rs | 57 + web/public/icon-maskable-512.png | Bin 0 -> 16673 bytes web/public/screenshots/narrow.png | Bin 0 -> 131998 bytes web/public/screenshots/wide.png | Bin 0 -> 103284 bytes web/src/App.tsx | 4 +- web/src/components/Settings.tsx | 30 + web/src/components/Sidebar.tsx | 6 + web/src/components/ToolCard.tsx | 8 + web/src/components/WhatsNew.tsx | 46 + web/src/components/settings/Account.tsx | 4 + web/src/components/settings/CliInstall.tsx | 44 + web/src/components/settings/Memory.tsx | 34 + web/src/i18n/de.ts | 23 + web/src/i18n/en.ts | 23 + web/src/install.ts | 41 + web/src/main.tsx | 1 + web/src/store.ts | 3 + web/src/styles.css | 32 + web/src/types.ts | 3 + web/src/whatsnew.ts | 19 + web/vite.config.ts | 12 +- 52 files changed, 6477 insertions(+), 765 deletions(-) create mode 100644 cli/src/agent.rs create mode 100644 cli/src/app.rs create mode 100644 cli/src/conf.rs create mode 100644 cli/src/editor.rs create mode 100644 cli/src/i18n.rs create mode 100644 cli/src/models.rs create mode 100644 cli/src/palettes.json create mode 100644 cli/src/server.rs create mode 100644 cli/src/session.rs create mode 100644 cli/src/stats.rs create mode 100644 cli/src/theme.rs create mode 100644 cli/src/tools.rs create mode 100644 core/src/prompts.rs create mode 100644 server/resources-cli/install.sh create mode 100644 server/src/update.rs create mode 100644 web/public/icon-maskable-512.png create mode 100644 web/public/screenshots/narrow.png create mode 100644 web/public/screenshots/wide.png create mode 100644 web/src/components/WhatsNew.tsx create mode 100644 web/src/components/settings/CliInstall.tsx create mode 100644 web/src/components/settings/Memory.tsx create mode 100644 web/src/install.ts create mode 100644 web/src/whatsnew.ts diff --git a/Cargo.lock b/Cargo.lock index f8bda08..ad3eb17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -117,6 +123,24 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "argon2" version = "0.6.0" @@ -150,6 +174,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -215,7 +248,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -276,6 +309,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -325,6 +379,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -366,6 +426,15 @@ dependencies = [ "zip", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.11.1" @@ -512,7 +581,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "shell-words", "similar", "tokio", @@ -531,13 +600,25 @@ name = "codecraft-cli" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", + "chrono", "clap", - "futures-util", + "codecraft-core", + "crossterm", + "eventsource-stream", + "futures", + "globset", + "ignore", + "ratatui", + "regex", "reqwest", "serde", "serde_json", + "similar", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-util", + "toml", + "unicode-width", ] [[package]] @@ -585,6 +666,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -600,6 +695,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -655,6 +759,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "croner" version = "4.0.3" @@ -663,7 +773,7 @@ checksum = "0097e28936e948e8f759478e1b5ec2e5ca70ab62cf38aaa9bd3aa660023e3fca" dependencies = [ "chrono", "derive_builder", - "strum", + "strum 0.27.2", ] [[package]] @@ -691,6 +801,34 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-bigint" version = "0.7.10" @@ -728,6 +866,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "ctr" version = "0.10.1" @@ -753,8 +901,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -771,17 +929,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.9", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -794,6 +976,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.8.3" @@ -805,6 +993,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_builder" version = "0.20.2" @@ -820,7 +1014,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.119", @@ -836,6 +1030,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -869,6 +1085,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -890,6 +1115,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "elliptic-curve" version = "0.14.3" @@ -942,6 +1173,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -965,6 +1205,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fast-float2" version = "0.2.4" @@ -990,12 +1240,35 @@ dependencies = [ "subtle", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "finl_unicode" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ded33109e99472f809cb7495544506eea45e366725dacf2a383e184f660456" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.11" @@ -1220,6 +1493,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1238,6 +1513,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.13.0" @@ -1264,7 +1545,7 @@ checksum = "389acaa5a86952dcdc88a43bc18021009cb9885b0ff8c2fff1cc44e4f34c2fae" dependencies = [ "html5ever", "tendril", - "thiserror", + "thiserror 2.0.21", "unicode-width", ] @@ -1586,6 +1867,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.2.6" @@ -1595,6 +1885,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1607,6 +1910,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1625,7 +1937,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -1683,6 +1995,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.1" @@ -1695,6 +2024,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.39.1" @@ -1706,6 +2041,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1718,6 +2062,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + [[package]] name = "lock_api" version = "0.4.14" @@ -1733,6 +2083,15 @@ version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -1745,6 +2104,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "markup5ever" version = "0.35.0" @@ -1788,6 +2157,21 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.18" @@ -1837,6 +2221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1863,16 +2248,29 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[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", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -1894,6 +2292,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1903,6 +2318,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1921,6 +2354,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "p256" version = "0.14.0" @@ -1931,7 +2373,40 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2", + "sha2 0.11.1", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -1982,6 +2457,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" +dependencies = [ + "memchr", + "pest_stack_guard", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pest_meta" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" +dependencies = [ + "pest", +] + +[[package]] +name = "pest_stack_guard" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac6154fd4418b8f0b82532c59c26f303f9ca59a64c883e8ce3971fd82733439" +dependencies = [ + "psm", + "stacker", +] + [[package]] name = "phc" version = "0.6.1" @@ -1999,6 +2527,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ + "phf_macros", "phf_shared", ] @@ -2022,6 +2551,19 @@ dependencies = [ "rand 0.8.8", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -2059,7 +2601,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -2077,6 +2619,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.6" @@ -2086,6 +2634,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2137,6 +2691,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pty-process" version = "0.5.4" @@ -2183,7 +2747,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -2206,7 +2770,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -2317,13 +2881,114 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -2426,7 +3091,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -2435,7 +3100,7 @@ version = "0.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2465,7 +3130,7 @@ version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -2603,7 +3268,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -2722,6 +3387,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -2754,6 +3430,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2856,6 +3553,25 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -2893,7 +3609,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2908,12 +3633,35 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -2967,13 +3715,109 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2996,6 +3840,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.5" @@ -3050,22 +3915,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite 0.28.0", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -3075,7 +3924,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -3165,7 +4014,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -3183,7 +4032,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -3283,25 +4132,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -3315,7 +4145,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -3330,6 +4160,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.10.0" @@ -3342,6 +4178,23 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.3" @@ -3409,6 +4262,7 @@ version = "1.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc" dependencies = [ + "atomic", "getrandom 0.4.3", "js-sys", "wasm-bindgen", @@ -3432,6 +4286,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3576,23 +4439,93 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "wezterm-bidi" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" dependencies = [ - "webpki-roots 1.0.11", + "log", + "wezterm-dynamic", ] [[package]] -name = "webpki-roots" -version = "1.0.11" +name = "wezterm-blob-leases" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ - "rustls-pki-types", + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", ] +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3602,6 +4535,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3906,7 +4845,7 @@ dependencies = [ "indexmap", "libc", "memchr", - "nix", + "nix 0.31.3", "typed-path", "windows-sys 0.61.2", "zeroize", diff --git a/Dockerfile b/Dockerfile index cdb1dc5..5c752ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,9 +22,31 @@ RUN mkdir -p core/src server/src cli/src \ && cargo build --release -p codecraft && rm -rf core/src server/src cli/src COPY core/src ./core/src COPY server/src ./server/src +COPY server/resources-cli ./server/resources-cli COPY cli/src ./cli/src +ARG GIT_COMMIT="" +ENV CODECRAFT_COMMIT=${GIT_COMMIT} RUN touch core/src/lib.rs server/src/main.rs && cargo build --release -p codecraft +# --- terminal client (static Linux binaries for x86_64 and aarch64) ----------- +FROM rust:1-bookworm AS cli +RUN apt-get update && apt-get install -y --no-install-recommends cmake xz-utils && rm -rf /var/lib/apt/lists/* +ARG ZIG_VERSION=0.17.1 +RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \ + && ln -s /opt/zig-*/zig /usr/local/bin/zig \ + && cargo install --locked cargo-zigbuild \ + && rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY core ./core +COPY server/Cargo.toml ./server/ +COPY server/resources ./server/resources +COPY cli ./cli +RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \ + && cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \ + && mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \ + && cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64 + # --- runtime ------------------------------------------------------------------ # Node is included so agents can work on JS projects and run npx-based MCP servers. FROM node:22-bookworm-slim @@ -42,6 +64,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force COPY --from=server /src/target/release/codecraft /usr/local/bin/codecraft COPY --from=web /web/dist /app/web +COPY --from=cli /out /app/cli COPY server/ghidra /app/ghidra COPY server/resources /app/resources # The agent's home lives on the data volume: tools it installs in user space diff --git a/cli/Cargo.toml b/cli/Cargo.toml index b90f953..069aa48 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -2,7 +2,7 @@ name = "codecraft-cli" version = "0.1.0" edition = "2024" -description = "Command line client for CodeCraft" +description = "CodeCraft in the terminal: a local coding agent using your CodeCraft server or your own model providers" license = "MIT" [[bin]] @@ -10,11 +10,23 @@ name = "codecraft" path = "src/main.rs" [dependencies] +codecraft-core = { path = "../core" } anyhow = "1" +base64 = "0.22" +chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } -futures-util = "0.3" -reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +crossterm = { version = "0.29", features = ["event-stream"] } +eventsource-stream = "0.2" +futures = "0.3" +globset = "0.4" +ignore = "0.4" +ratatui = "0.30" +regex = "1" +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "fs", "time", "signal"] } -tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +similar = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "fs", "time", "process", "sync", "signal"] } +tokio-util = "0.7" +toml = "1" +unicode-width = "0.2" diff --git a/cli/src/agent.rs b/cli/src/agent.rs new file mode 100644 index 0000000..db956a6 --- /dev/null +++ b/cli/src/agent.rs @@ -0,0 +1,552 @@ +//! The local agent loop: model requests (server gateway or own provider), +//! tool calls with permission checks, subagents, compaction, persistence. + +use crate::conf::Config; +use crate::models::{Catalog, ModelInfo}; +use crate::server::Server; +use crate::session::LocalSession; +use crate::tools::{self, Ctx, ToolSpec}; +use anyhow::{Result, anyhow}; +use codecraft_core::config::PermissionConfig; +use codecraft_core::permission::{Decision, Evaluator, bash_prefix, split_command}; +use codecraft_core::provider::StreamEvent; +use codecraft_core::types::{ImageData, Message, Part, Question, Role, Usage}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +const MAX_STEPS: usize = 200; +const COMPACT_AT: f64 = 0.85; + +/// A mode (agent) as defined by the server or built in. +#[derive(Debug, Clone)] +pub struct Mode { + pub name: String, + pub description: String, + pub primary: bool, + pub prompt: String, + pub read_only: bool, + pub tools: Option>, + pub model: Option, +} + +pub fn builtin_modes() -> Vec { + use codecraft_core::prompts::*; + let m = |name: &str, description: &str, primary: bool, prompt: String, read_only: bool| Mode { name: name.into(), description: description.into(), primary, prompt, read_only, tools: None, model: None }; + vec![ + m("build", "Full access: implement, edit, run", true, BUILD_PROMPT.into(), false), + m("plan", "Read-only: analyse and propose a plan", true, PLAN_PROMPT.into(), true), + m("craft", "Study a target, build your own version, check and refine", true, craft_prompt(true), false), + m("general", "General-purpose subagent for multi-step tasks", false, GENERAL_PROMPT.into(), false), + m("explore", "Read-only subagent for fast codebase searches", false, EXPLORE_PROMPT.into(), true), + ] +} + +pub fn modes_from_server(cfg: &Value) -> Vec { + cfg["agents"] + .as_array() + .into_iter() + .flatten() + .map(|a| Mode { + name: a["name"].as_str().unwrap_or("").into(), + description: a["description"].as_str().unwrap_or("").into(), + primary: a["mode"] == "primary", + prompt: a["prompt"].as_str().unwrap_or("").into(), + read_only: a["read_only"].as_bool().unwrap_or(false), + tools: a["tools"].as_array().map(|t| t.iter().filter_map(|x| x.as_str().map(String::from)).collect()), + model: a["model"].as_str().map(|m| format!("{}{m}", crate::models::SERVER_PREFIX)), + }) + .filter(|m| !m.name.is_empty()) + .collect() +} + +/// Everything the agent needs from the environment (shared, read-mostly). +pub struct Env { + pub cfg: Config, + pub server: Option, + pub server_cfg: Option, + pub catalog: Catalog, + pub modes: Vec, + pub permissions: PermissionConfig, + pub base_prompt: String, + pub auto_prompt: String, + pub remote_tools: Vec, + pub server_skills: Vec<(String, String)>, + pub memory: String, +} + +impl Env { + pub fn mode(&self, name: &str) -> Mode { + self.modes.iter().find(|m| m.name == name).cloned().unwrap_or_else(|| builtin_modes().remove(0)) + } +} + +pub enum Reply { + Once, + Always, + Reject(Option), +} + +/// Events for the UI. +pub enum UiEvent { + Busy(bool), + Text(String), + Reasoning(String), + ToolStart { id: String, name: String, input: Value }, + ToolDone { id: String, content: String, is_error: bool, diff: Option }, + Todos(Value), + Usage { context: u64, total_cost: f64 }, + Permission { tool: String, title: String, input: Value, diff: Option, reply: oneshot::Sender }, + Question { questions: Vec, reply: oneshot::Sender>> }, + Info(String), + Error(String), + /// A new step (model request) begins. + Step, +} + +pub struct Agent { + pub env: Arc, + pub session: LocalSession, + approved: HashSet, + read_files: Arc>>, + shells: Arc>>>, + ui: mpsc::UnboundedSender, +} + +fn instruction_files(cwd: &Path) -> Vec<(PathBuf, String)> { + let mut out = Vec::new(); + let home = crate::conf::home(); + let mut dirs: Vec = cwd.ancestors().take_while(|d| *d != home && d.parent().is_some()).map(Path::to_path_buf).collect(); + dirs.reverse(); + for d in dirs { + for name in ["AGENTS.md", "CLAUDE.md"] { + let p = d.join(name); + if let Ok(text) = std::fs::read_to_string(&p) { + out.push((p, text.chars().take(40_000).collect())); + break; + } + } + } + out +} + +impl Agent { + pub fn new(env: Arc, session: LocalSession, ui: mpsc::UnboundedSender) -> Self { + Self { env, session, approved: HashSet::new(), read_files: Arc::default(), shells: Arc::default(), ui } + } + + fn emit(&self, ev: UiEvent) { + let _ = self.ui.send(ev); + } + + fn model(&self, spec: &str) -> Result { + self.env.catalog.find(spec).cloned().ok_or_else(|| anyhow!("model '{spec}' is not available – choose one with /model")) + } + + fn tools_for(&self, mode: &Mode, depth: usize) -> Vec { + let mut all = tools::local_tools(); + all.extend(self.env.remote_tools.iter().map(|t| ToolSpec { name: t.name.clone(), description: t.description.clone(), schema: t.schema.clone(), read_only: t.read_only, is_edit: false, remote: true })); + all.into_iter() + .filter(|t| !mode.read_only || t.read_only) + .filter(|t| !(t.name == "task" && depth >= 2)) + .filter(|t| match &mode.tools { + None => true, + Some(list) => list.iter().any(|a| a.eq_ignore_ascii_case(&t.name) || (t.remote && a == "mcp")), + }) + .collect() + } + + fn system_prompt(&self, mode: &Mode, model: &str) -> String { + let env = &self.env; + let cwd = PathBuf::from(&self.session.cwd); + let mut s = format!("{}\n\n{}", env.base_prompt, mode.prompt); + if self.session.permission_mode == "auto" && !mode.read_only { + s.push_str(&env.auto_prompt); + } + s.push_str(&format!( + "\n\n# Environment\n- Working directory: {}\n- Git repository: {}\n- Platform: {} ({}), local machine of the user (no sandbox – commands run with the user's rights)\n- Model: {model}\n- Date: {}\n", + cwd.display(), + if cwd.join(".git").exists() { "yes" } else { "no" }, + std::env::consts::OS, + std::env::consts::ARCH, + chrono::Local::now().format("%Y-%m-%d"), + )); + let subs: Vec<&Mode> = env.modes.iter().filter(|m| !m.primary).collect(); + if !mode.read_only && !subs.is_empty() { + s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type):\n"); + for m in subs { + s.push_str(&format!("- {}{}: {}\n", m.name, if m.read_only { " (read-only)" } else { "" }, m.description)); + } + } + let mut skills: Vec<(String, String)> = env.server_skills.clone(); + for name in tools::builtin_skill_names() { + if !skills.iter().any(|(n, _)| n == name) { + let desc = tools::BUILTIN + .iter() + .find(|(p, _)| *p == format!("skills/{name}/SKILL.md")) + .map(|(_, c)| codecraft_core_frontmatter(c).0.get("description").cloned().unwrap_or_default()) + .unwrap_or_default(); + skills.push((name.to_string(), desc)); + } + } + for (name, p) in tools::project_skills(&cwd) { + let desc = std::fs::read_to_string(&p).map(|c| codecraft_core_frontmatter(&c).0.get("description").cloned().unwrap_or_default()).unwrap_or_default(); + skills.retain(|(n, _)| n != &name); + skills.push((name, desc)); + } + if !skills.is_empty() { + s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n"); + for (n, d) in &skills { + s.push_str(&format!("- {n}: {d}\n")); + } + } + if !env.memory.trim().is_empty() { + s.push_str(&format!("\n# About the user (personal memory)\n{}\n", env.memory.trim())); + } + if let Ok(mem) = std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { + s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::())); + } else if !mode.read_only { + s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"); + } + let files = instruction_files(&cwd); + if !files.is_empty() { + s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n"); + for (p, text) in files { + s.push_str(&format!("\n\n{}\n\n", p.display(), text.trim())); + } + } + s + } + + /// One model request; returns the assistant message and the stop reason. + async fn step(&mut self, model: &ModelInfo, system: String, messages: Vec, tools: &[ToolSpec], effort: Option, mode: &str, cancel: &CancellationToken, live: bool) -> Result<(Message, String, Usage)> { + let (tx, mut rx) = mpsc::channel(256); + let env = self.env.clone(); + let m = model.clone(); + let defs = tools.iter().map(ToolSpec::def).collect(); + let sid = self.session.id.clone(); + let agent = mode.to_string(); + let c = cancel.clone(); + let handle = tokio::spawn(async move { crate::models::stream(&env.cfg, env.server.as_ref(), &m, system, messages, defs, effort, &sid, &agent, tx, c).await }); + let mut parts: Vec = Vec::new(); + let mut usage = Usage::default(); + let mut stop = String::new(); + while let Some(ev) = rx.recv().await { + match ev { + StreamEvent::TextDelta(t) => { + if live { + self.emit(UiEvent::Text(t.clone())); + } + match parts.last_mut() { + Some(Part::Text { text }) => text.push_str(&t), + _ => parts.push(Part::Text { text: t }), + } + } + StreamEvent::ReasoningDelta(t) => { + if live { + self.emit(UiEvent::Reasoning(t.clone())); + } + match parts.last_mut() { + Some(Part::Reasoning { text, .. }) => text.push_str(&t), + _ => parts.push(Part::Reasoning { text: t, signature: None }), + } + } + StreamEvent::ReasoningSignature(sig) => { + if let Some(Part::Reasoning { signature, .. }) = parts.iter_mut().rev().find(|p| matches!(p, Part::Reasoning { .. })) { + *signature = Some(sig); + } + } + StreamEvent::ToolCall { id, name, input, extra } => parts.push(Part::ToolCall { id, name, input, extra }), + StreamEvent::Usage(u) => usage = u, + StreamEvent::Done { stop_reason } => stop = stop_reason, + } + } + handle.await.map_err(|e| anyhow!("{e}"))??; + let mut msg = Message::new(Role::Assistant, parts); + msg.model = Some(model.spec.clone()); + Ok((msg, stop, usage)) + } + + fn account(&mut self, model: &ModelInfo, usage: &Usage) { + let cost = model.cost(usage); + self.session.usage.input_tokens += usage.input_tokens; + self.session.usage.output_tokens += usage.output_tokens; + self.session.usage.cache_read_tokens += usage.cache_read_tokens; + self.session.usage.cache_write_tokens += usage.cache_write_tokens; + self.session.cost += cost; + self.session.context_tokens = usage.context_tokens(); + crate::stats::record(&self.session, &model.spec, usage, cost); + self.emit(UiEvent::Usage { context: usage.context_tokens(), total_cost: self.session.cost }); + } + + /// Run one user turn. + pub async fn prompt(&mut self, text: String, images: Vec, effort: Option, cancel: CancellationToken) { + self.emit(UiEvent::Busy(true)); + if self.session.title.is_empty() { + self.session.title = text.lines().next().unwrap_or("").chars().take(70).collect(); + } + let mut parts = vec![Part::Text { text }]; + parts.extend(images.into_iter().map(|image| Part::Image { image })); + self.session.messages.push(Message::new(Role::User, parts)); + if let Err(e) = self.run_loop(effort, &cancel).await { + self.emit(UiEvent::Error(format!("{e:#}"))); + } + let _ = self.session.save(); + self.emit(UiEvent::Busy(false)); + } + + async fn run_loop(&mut self, effort: Option, cancel: &CancellationToken) -> Result<()> { + for _ in 0..MAX_STEPS { + if cancel.is_cancelled() { + break; + } + let mode = self.env.mode(&self.session.mode); + let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?; + // Compact before the context window overflows. + if let Some(ctx) = model.config.context { + if self.session.context_tokens as f64 > ctx as f64 * COMPACT_AT { + self.compact(&model, cancel).await?; + } + } + let tools = self.tools_for(&mode, 0); + self.emit(UiEvent::Step); + let system = self.system_prompt(&mode, &model.spec); + let (msg, stop, usage) = self.step(&model, system, self.session.active(), &tools, effort.clone(), &mode.name, cancel, true).await?; + self.account(&model, &usage); + let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect(); + self.session.messages.push(msg); + let _ = self.session.save(); + if calls.is_empty() { + if stop == "max_tokens" || stop == "length" { + self.emit(UiEvent::Error("The answer hit the output token limit.".into())); + } + break; + } + let mut results = Vec::new(); + for (id, name, input) in calls { + if cancel.is_cancelled() { + results.push(tool_result(&id, &name, "Aborted by user".into(), true)); + continue; + } + let part = self.execute(&tools, &id, &name, input, &mode, cancel, 0).await; + results.push(part); + } + self.session.messages.push(Message::new(Role::User, results)); + let _ = self.session.save(); + } + Ok(()) + } + + fn ctx(&self, cancel: &CancellationToken) -> Ctx { + Ctx { + cwd: PathBuf::from(&self.session.cwd), + cancel: cancel.clone(), + read_files: self.read_files.clone(), + shells: self.shells.clone(), + server: self.env.server.clone(), + server_skills: self.env.server_skills.iter().map(|(n, _)| n.clone()).collect(), + } + } + + /// Permission check, then run the tool. + #[allow(clippy::too_many_arguments)] + fn execute<'a>(&'a mut self, tools: &'a [ToolSpec], id: &'a str, name: &'a str, input: Value, _mode: &'a Mode, cancel: &'a CancellationToken, depth: usize) -> futures::future::BoxFuture<'a, Part> { + Box::pin(async move { + let Some(spec) = tools.iter().find(|t| t.name == name) else { + return tool_result(id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true); + }; + if depth == 0 { + self.emit(UiEvent::ToolStart { id: id.into(), name: name.into(), input: input.clone() }); + } + let decision = Evaluator { cfg: &self.env.permissions, mode: &self.session.permission_mode, approved: &self.approved }.evaluate(spec, &input); + match decision { + Decision::Deny => return self.finish(id, name, "Denied by the permission rules.".into(), true, None, depth), + Decision::Ask => { + let (tx, rx) = oneshot::channel(); + let cwd = PathBuf::from(&self.session.cwd); + let title = match name { + "bash" => input["description"].as_str().or(input["command"].as_str()).unwrap_or("").to_string(), + _ => input["path"].as_str().map(|p| format!("{name} {p}")).unwrap_or_else(|| name.to_string()), + }; + self.emit(UiEvent::Permission { tool: name.into(), title, input: input.clone(), diff: tools::preview(name, &input, &cwd), reply: tx }); + let reply = tokio::select! { + r = rx => r.unwrap_or(Reply::Reject(None)), + _ = cancel.cancelled() => Reply::Reject(Some("aborted".into())), + }; + match reply { + Reply::Reject(msg) => { + let mut text = "The user rejected this tool call.".to_string(); + if let Some(m) = msg.filter(|m| !m.trim().is_empty()) { + text.push_str(&format!(" User feedback: {m}")); + } + return self.finish(id, name, text, true, None, depth); + } + Reply::Always => { + if name == "bash" { + for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() { + self.approved.insert(format!("bash:{}", bash_prefix(&part))); + } + } else { + self.approved.insert(name.to_string()); + } + } + Reply::Once => {} + } + } + Decision::Allow => {} + } + match name { + "ask_user" => { + let questions: Vec = serde_json::from_value(input["questions"].clone()).unwrap_or_default(); + let (tx, rx) = oneshot::channel(); + self.emit(UiEvent::Question { questions, reply: tx }); + let answers = tokio::select! { r = rx => r.ok().flatten(), _ = cancel.cancelled() => None }; + match answers { + Some(a) => self.finish(id, name, serde_json::to_string_pretty(&a).unwrap_or_default(), false, None, depth), + None => self.finish(id, name, "The user dismissed the questions without answering.".into(), true, None, depth), + } + } + "todo_write" => { + self.session.todos = input["todos"].clone(); + self.emit(UiEvent::Todos(input["todos"].clone())); + let ctx = self.ctx(cancel); + match tools::run(&ctx, name, &input).await { + Ok(o) => self.finish(id, name, o.content, false, None, depth), + Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), + } + } + "task" => { + let text = self.subagent(&input, cancel, depth + 1).await; + match text { + Ok(t) => self.finish(id, name, t, false, None, depth), + Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), + } + } + _ if spec.remote => { + let server = self.env.server.clone(); + match server { + Some(s) => match s.tool(name, input).await { + Ok((content, err)) => self.finish(id, name, content, err, None, depth), + Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), + }, + None => self.finish(id, name, "not connected to the server".into(), true, None, depth), + } + } + _ => { + let ctx = self.ctx(cancel); + match tools::run(&ctx, name, &input).await { + Ok(o) => { + let images = o.images.clone(); + let mut part = self.finish(id, name, o.content, o.is_error, o.diff, depth); + if let Part::ToolResult { images: imgs, .. } = &mut part { + *imgs = images; + } + part + } + Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), + } + } + } + }) + } + + fn finish(&self, id: &str, name: &str, content: String, is_error: bool, diff: Option, depth: usize) -> Part { + if depth == 0 { + self.emit(UiEvent::ToolDone { id: id.into(), content: content.clone(), is_error, diff: diff.clone() }); + } + let mut part = tool_result(id, name, content, is_error); + if let (Some(d), Part::ToolResult { meta, .. }) = (diff, &mut part) { + *meta = Some(json!({"diff": d})); + } + part + } + + /// Run a subagent with a fresh context; returns its final report. + async fn subagent(&mut self, input: &Value, cancel: &CancellationToken, depth: usize) -> Result { + let kind = input["subagent_type"].as_str().unwrap_or("general"); + let parent = self.env.mode(&self.session.mode); + let mut mode = self.env.modes.iter().find(|m| m.name == kind && !m.primary).cloned().unwrap_or_else(|| builtin_modes().into_iter().find(|m| m.name == "general").unwrap()); + if parent.read_only && !mode.read_only { + mode = builtin_modes().into_iter().find(|m| m.name == "explore").unwrap(); + } + let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?; + let tools = self.tools_for(&mode, depth); + let prompt = input["prompt"].as_str().unwrap_or("").to_string(); + self.emit(UiEvent::Info(format!("subagent {kind}: {}", input["description"].as_str().unwrap_or("")))); + let mut messages = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])]; + let mut last = String::new(); + for _ in 0..MAX_STEPS { + if cancel.is_cancelled() { + break; + } + let system = self.system_prompt(&mode, &model.spec); + let (msg, _, usage) = self.step(&model, system, messages.clone(), &tools, None, &mode.name, cancel, false).await?; + self.account(&model, &usage); + last = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join(""); + let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect(); + messages.push(msg); + if calls.is_empty() { + break; + } + let mut results = Vec::new(); + for (id, name, input) in calls { + results.push(self.execute(&tools, &id, &name, input, &mode, cancel, depth).await); + } + messages.push(Message::new(Role::User, results)); + } + Ok(if last.trim().is_empty() { "(the subagent returned no report)".into() } else { last }) + } + + /// Replace the history with a summary. + pub async fn compact(&mut self, model: &ModelInfo, cancel: &CancellationToken) -> Result<()> { + self.emit(UiEvent::Info("compacting the conversation…".into())); + let mut transcript = String::new(); + for m in self.session.active() { + let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" }; + for p in &m.parts { + let line = match p { + Part::Text { text } | Part::Summary { text } => text.clone(), + Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::()), + Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::()), + _ => continue, + }; + transcript.push_str(&format!("### {who}\n{line}\n")); + } + } + let prompt = format!("\n{transcript}\n\n\nSummarize this coding session so far so that the work can continue seamlessly from the summary alone: goals and explicit requests, decisions, files changed and their state, commands/tests and results, open problems and the exact next steps."); + let msgs = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])]; + let (msg, _, usage) = self.step(model, "You are a precise summarizer of software engineering sessions. Reply with the summary only.".into(), msgs, &[], None, "compact", cancel, false).await?; + self.account(model, &usage); + let summary: String = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect(); + if summary.trim().is_empty() { + return Err(anyhow!("compaction produced no summary")); + } + self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }])); + self.session.context_tokens = 0; + self.read_files.lock().unwrap().clear(); + let _ = self.session.save(); + self.emit(UiEvent::Info("conversation compacted".into())); + Ok(()) + } +} + +fn tool_result(id: &str, name: &str, content: String, is_error: bool) -> Part { + Part::ToolResult { call_id: id.into(), name: name.into(), content, images: vec![], is_error, meta: None } +} + +/// Parse "---\nkey: value\n---\nbody" frontmatter. +pub fn codecraft_core_frontmatter(text: &str) -> (BTreeMap, String) { + let mut meta = BTreeMap::new(); + let Some(rest) = text.strip_prefix("---\n") else { return (meta, text.to_string()) }; + let Some(end) = rest.find("\n---") else { return (meta, text.to_string()) }; + for line in rest[..end].lines() { + if let Some((k, v)) = line.split_once(':') { + meta.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string()); + } + } + let body = rest[end + 4..].trim_start_matches('\n').to_string(); + (meta, body) +} diff --git a/cli/src/app.rs b/cli/src/app.rs new file mode 100644 index 0000000..7b626c6 --- /dev/null +++ b/cli/src/app.rs @@ -0,0 +1,1632 @@ +//! The full-screen terminal UI. + +use crate::agent::{Env, Reply, UiEvent}; +use crate::editor::Editor; +use crate::i18n::t; +use crate::session::LocalSession; +use crate::theme::{self, Theme}; +use anyhow::Result; +use base64::Engine; +use codecraft_core::types::{ImageData, Part, Question, Role}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; +use futures::StreamExt; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; +use serde_json::Value; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +use unicode_width::UnicodeWidthStr; + +// --- conversation items --------------------------------------------------------------- + +enum ToolState { + Running, + Ok, + Err, +} + +enum Item { + User(String, usize), + Assistant(String), + Thinking(String, bool), + Tool { id: String, name: String, title: String, state: ToolState, output: String, diff: Option, open: bool }, + Info(String), + Error(String), +} + +pub enum Cmd { + Prompt { text: String, images: Vec, effort: Option, cancel: CancellationToken }, + SetModel(String), + SetMode(String), + SetPermission(String), + Load(Box), + New, + Compact(CancellationToken), + SetEnv(Arc), +} + +#[derive(Clone, Copy, PartialEq)] +enum PickKind { + Model, + Mode, + Session, + Theme, + Permission, + Effort, + Lang, +} + +struct Picker { + kind: PickKind, + title: String, + items: Vec<(String, String, String)>, + filter: String, + sel: usize, +} + +impl Picker { + fn visible(&self) -> Vec<&(String, String, String)> { + let f = self.filter.to_lowercase(); + self.items.iter().filter(|(k, l, d)| f.is_empty() || k.to_lowercase().contains(&f) || l.to_lowercase().contains(&f) || d.to_lowercase().contains(&f)).collect() + } +} + +enum SetupStep { + Choose { sel: usize, detected: Vec<&'static str>, note: String }, + Server { fields: [String; 3], focus: usize, error: String }, + Provider { preset: usize, fields: [String; 3], focus: usize, error: String }, +} + +enum Dialog { + None, + Permission { tool: String, title: String, diff: Option, input: Value, reply: Option>, feedback: Option }, + Question { questions: Vec, idx: usize, sel: usize, custom: String, answers: serde_json::Map, reply: Option>>> }, + Picker(Picker), + Setup(SetupStep), + Text { title: String, body: String }, +} + +struct Completion { + start: usize, + items: Vec<(String, String)>, + sel: usize, +} + +pub struct App { + env: Arc, + items: Vec, + scroll: usize, + editor: Editor, + completion: Option, + busy: bool, + busy_since: Instant, + cancel: Option, + queue: VecDeque, + dialog: Dialog, + session: LocalSession, + effort: Option, + images: Vec, + todos: Value, + context: u64, + status: String, + quit_armed: Option, + quit: bool, + cmd: mpsc::UnboundedSender, + /// History line -> item index (mouse clicks), and the history area. + hit: Vec>, + history_area: Rect, + tick: usize, + step_chars: usize, + pending_jobs: Option>>, + pending_detect: Option>>, + /// Running setup action (login / provider test): Ok(message) or error. + pending_setup: Option>>, + pending_env: Option>>, +} + +const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +fn wrap(text: &str, width: usize) -> Vec { + let width = width.max(10); + let mut out = Vec::new(); + for raw in text.split('\n') { + if raw.is_empty() { + out.push(String::new()); + continue; + } + let mut line = String::new(); + let mut w = 0; + for word in raw.split_inclusive(' ') { + let ww = UnicodeWidthStr::width(word); + if w + ww > width && !line.is_empty() { + out.push(std::mem::take(&mut line).trim_end().to_string()); + w = 0; + } + if ww > width { + // Hard-break very long words. + for ch in word.chars() { + let cw = UnicodeWidthStr::width(ch.to_string().as_str()); + if w + cw > width { + out.push(std::mem::take(&mut line)); + w = 0; + } + line.push(ch); + w += cw; + } + } else { + line.push_str(word); + w += ww; + } + } + out.push(line.trim_end().to_string()); + } + out +} + +/// Inline markdown: `code` and **bold**. +fn inline(text: &str, th: &Theme, base: Style) -> Vec> { + let mut spans = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + let next_code = rest.find('`'); + let next_bold = rest.find("**"); + match (next_code, next_bold) { + (Some(c), b) if b.is_none_or(|b| c < b) => { + if let Some(end) = rest[c + 1..].find('`') { + spans.push(Span::styled(rest[..c].to_string(), base)); + spans.push(Span::styled(rest[c + 1..c + 1 + end].to_string(), Style::default().fg(th.accent2))); + rest = &rest[c + 2 + end..]; + continue; + } + spans.push(Span::styled(rest.to_string(), base)); + break; + } + (_, Some(b)) => { + if let Some(end) = rest[b + 2..].find("**") { + spans.push(Span::styled(rest[..b].to_string(), base)); + spans.push(Span::styled(rest[b + 2..b + 2 + end].to_string(), base.add_modifier(Modifier::BOLD))); + rest = &rest[b + 4 + end..]; + continue; + } + spans.push(Span::styled(rest.to_string(), base)); + break; + } + _ => { + spans.push(Span::styled(rest.to_string(), base)); + break; + } + } + } + spans +} + +fn markdown(text: &str, width: usize, th: &Theme) -> Vec> { + let mut out = Vec::new(); + let mut in_code = false; + let base = Style::default().fg(th.fg1); + for raw in text.split('\n') { + if raw.trim_start().starts_with("```") { + in_code = !in_code; + let lang = raw.trim_start().trim_start_matches('`'); + if in_code && !lang.is_empty() { + out.push(Line::from(Span::styled(format!(" {lang}"), Style::default().fg(th.muted)))); + } + continue; + } + if in_code { + for l in wrap(raw, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {l}"), Style::default().fg(th.fg2).bg(th.bg1)))); + } + continue; + } + let trimmed = raw.trim_start(); + if let Some(h) = trimmed.strip_prefix("# ").or(trimmed.strip_prefix("## ")).or(trimmed.strip_prefix("### ")) { + for l in wrap(h, width) { + out.push(Line::from(Span::styled(l, Style::default().fg(th.accent).add_modifier(Modifier::BOLD)))); + } + continue; + } + let (prefix, body) = if let Some(b) = trimmed.strip_prefix("- ").or(trimmed.strip_prefix("* ")) { + let indent = raw.len() - trimmed.len(); + (format!("{}• ", " ".repeat(indent)), b) + } else { + (String::new(), raw) + }; + let pw = prefix.chars().count(); + for (i, l) in wrap(body, width.saturating_sub(pw)).into_iter().enumerate() { + let mut spans = vec![Span::styled(if i == 0 { prefix.clone() } else { " ".repeat(pw) }, Style::default().fg(th.accent))]; + spans.extend(inline(&l, th, base)); + out.push(Line::from(spans)); + } + } + out +} + +fn diff_lines(diff: &str, max: usize, th: &Theme) -> Vec> { + let mut out: Vec = diff + .lines() + .filter(|l| !l.starts_with("---") && !l.starts_with("+++")) + .take(max) + .map(|l| { + let st = if l.starts_with('+') { + Style::default().fg(th.green) + } else if l.starts_with('-') { + Style::default().fg(th.red) + } else if l.starts_with("@@") { + Style::default().fg(th.blue) + } else { + Style::default().fg(th.fg3) + }; + Line::from(Span::styled(format!(" {l}"), st)) + }) + .collect(); + let total = diff.lines().count(); + if total > max + 2 { + out.push(Line::from(Span::styled(format!(" … {} more lines", total - max - 2), Style::default().fg(th.muted)))); + } + out +} + +fn tool_title(name: &str, input: &Value) -> String { + let s = |k: &str| input[k].as_str().unwrap_or("").to_string(); + match name { + "bash" => input["description"].as_str().map(String::from).unwrap_or_else(|| s("command")), + "read" | "write" | "edit" | "list" => s("path"), + "glob" | "grep" => s("pattern"), + "task" => s("description"), + "skill" => s("name"), + "remember" => s("note"), + _ => input.to_string().chars().take(80).collect(), + } +} + +impl App { + pub fn new(env: Arc, session: LocalSession, cmd: mpsc::UnboundedSender) -> Self { + let mut app = Self { + env, + items: vec![], + scroll: 0, + editor: Editor::new(), + completion: None, + busy: false, + busy_since: Instant::now(), + cancel: None, + queue: VecDeque::new(), + dialog: Dialog::None, + todos: session.todos.clone(), + context: session.context_tokens, + session, + effort: None, + images: vec![], + status: String::new(), + quit_armed: None, + quit: false, + cmd, + hit: vec![], + history_area: Rect::default(), + tick: 0, + step_chars: 0, + pending_jobs: None, + pending_detect: None, + pending_setup: None, + pending_env: None, + }; + app.rebuild_items(); + app + } + + /// Rebuild the view from a (loaded) session. + fn rebuild_items(&mut self) { + self.items.clear(); + for m in &self.session.messages { + for p in &m.parts { + match (&m.role, p) { + (Role::User, Part::Text { text }) => self.items.push(Item::User(text.clone(), 0)), + (Role::User, Part::Summary { .. }) => self.items.push(Item::Info("— conversation compacted —".into())), + (_, Part::Text { text }) => self.items.push(Item::Assistant(text.clone())), + (_, Part::Reasoning { text, .. }) if !text.trim().is_empty() => self.items.push(Item::Thinking(text.clone(), false)), + (_, Part::ToolCall { id, name, input, .. }) => self.items.push(Item::Tool { id: id.clone(), name: name.clone(), title: tool_title(name, input), state: ToolState::Ok, output: String::new(), diff: None, open: false }), + (_, Part::ToolResult { call_id, content, is_error, meta, .. }) => { + if let Some(Item::Tool { state, output, diff, .. }) = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id, .. } if id == call_id)) { + *state = if *is_error { ToolState::Err } else { ToolState::Ok }; + *output = content.clone(); + *diff = meta.as_ref().and_then(|m| m["diff"].as_str().map(String::from)); + } + } + _ => {} + } + } + } + } + + fn model_label(&self) -> String { + self.env.catalog.find(&self.session.model).map(|m| m.label.clone()).unwrap_or_else(|| self.session.model.clone()) + } + + // --- events from the agent -------------------------------------------------------- + + fn on_agent(&mut self, ev: UiEvent) { + match ev { + UiEvent::Busy(b) => { + self.busy = b; + if b { + self.busy_since = Instant::now(); + } else { + self.cancel = None; + if let Some(next) = self.queue.pop_front() { + self.send(next); + } + } + } + UiEvent::Step => self.step_chars = 0, + UiEvent::Text(t) => { + self.step_chars += t.len(); + match self.items.last_mut() { + Some(Item::Assistant(s)) => s.push_str(&t), + _ => self.items.push(Item::Assistant(t)), + } + } + UiEvent::Reasoning(t) => { + self.step_chars += t.len(); + match self.items.last_mut() { + Some(Item::Thinking(s, _)) => s.push_str(&t), + _ => self.items.push(Item::Thinking(t, false)), + } + } + UiEvent::ToolStart { id, name, input } => self.items.push(Item::Tool { id, title: tool_title(&name, &input), name, state: ToolState::Running, output: String::new(), diff: None, open: false }), + UiEvent::ToolDone { id, content, is_error, diff } => { + if let Some(Item::Tool { state, output, diff: d, .. }) = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id)) { + *state = if is_error { ToolState::Err } else { ToolState::Ok }; + *output = content; + *d = diff; + } + } + UiEvent::Todos(v) => self.todos = v, + UiEvent::Usage { context, total_cost, .. } => { + self.context = context; + self.session.cost = total_cost; + } + UiEvent::Permission { tool, title, input, diff, reply } => self.dialog = Dialog::Permission { tool, title, diff, input, reply: Some(reply), feedback: None }, + UiEvent::Question { questions, reply } => self.dialog = Dialog::Question { questions, idx: 0, sel: 0, custom: String::new(), answers: Default::default(), reply: Some(reply) }, + UiEvent::Info(s) => self.items.push(Item::Info(s)), + UiEvent::Error(e) => self.items.push(Item::Error(e)), + } + self.scroll = 0; + } + + // --- sending --------------------------------------------------------------------------- + + fn send(&mut self, text: String) { + if self.env.catalog.find(&self.session.model).is_none() { + self.items.push(Item::Error(t("no_model").into())); + return; + } + // @image.png mentions are attached as images. + let cwd = PathBuf::from(&self.session.cwd); + for word in text.split_whitespace() { + if let Some(p) = word.strip_prefix('@') { + let path = crate::tools::resolve(&cwd, p); + let mt = match path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { + Some("png") => Some("image/png"), + Some("jpg") | Some("jpeg") => Some("image/jpeg"), + Some("webp") => Some("image/webp"), + Some("gif") => Some("image/gif"), + _ => None, + }; + if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) { + self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); + } + } + } + let images = std::mem::take(&mut self.images); + self.items.push(Item::User(text.clone(), images.len())); + let cancel = CancellationToken::new(); + self.cancel = Some(cancel.clone()); + self.busy = true; + self.busy_since = Instant::now(); + let effort = self.effort.take(); + let _ = self.cmd.send(Cmd::Prompt { text, images, effort, cancel }); + self.scroll = 0; + } + + fn submit(&mut self) { + let text = self.editor.take(); + let trimmed = text.trim().to_string(); + if trimmed.is_empty() && self.images.is_empty() { + return; + } + if trimmed.starts_with('/') && self.slash(&trimmed) { + return; + } + let text = self.expand_command(&trimmed).unwrap_or(trimmed); + if self.busy { + self.queue.push_back(text); + self.status = format!("{} ({})", t("queued"), self.queue.len()); + } else { + self.send(text); + } + } + + /// Custom commands (server, project, built-in): "/name args" -> prompt. + fn expand_command(&mut self, text: &str) -> Option { + let rest = text.strip_prefix('/')?; + let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let body = self.find_command(name)?; + let (meta, body) = crate::agent::codecraft_core_frontmatter(&body); + if let Some(agent) = meta.get("agent") { + self.set_mode(agent); + } + Some(body.replace("$ARGUMENTS", args.trim())) + } + + fn commands(&self) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let cwd = PathBuf::from(&self.session.cwd); + for base in [".claude", ".codecraft"] { + if let Ok(entries) = std::fs::read_dir(cwd.join(base).join("commands")) { + for e in entries.flatten() { + let p = e.path(); + if p.extension().is_some_and(|x| x == "md") { + let desc = std::fs::read_to_string(&p).map(|c| crate::agent::codecraft_core_frontmatter(&c).0.get("description").cloned().unwrap_or_default()).unwrap_or_default(); + out.push((p.file_stem().unwrap().to_string_lossy().to_string(), desc)); + } + } + } + } + if let Some(sc) = &self.env.server_cfg { + for c in sc["commands"].as_array().into_iter().flatten() { + out.push((c["name"].as_str().unwrap_or("").into(), c["description"].as_str().unwrap_or("").into())); + } + } + for (p, c) in crate::tools::BUILTIN { + if let Some(name) = p.strip_prefix("commands/").and_then(|n| n.strip_suffix(".md")) { + out.push((name.into(), crate::agent::codecraft_core_frontmatter(c).0.get("description").cloned().unwrap_or_default())); + } + } + let mut seen = std::collections::HashSet::new(); + out.retain(|(n, _)| !n.is_empty() && seen.insert(n.clone())); + out + } + + fn find_command(&self, name: &str) -> Option { + let cwd = PathBuf::from(&self.session.cwd); + for base in [".codecraft", ".claude"] { + if let Ok(c) = std::fs::read_to_string(cwd.join(base).join("commands").join(format!("{name}.md"))) { + return Some(c); + } + } + if let Some(sc) = &self.env.server_cfg { + if let Some(c) = sc["commands"].as_array().into_iter().flatten().find(|c| c["name"] == name) { + let agent = c["agent"].as_str().map(|a| format!("---\nagent: {a}\n---\n")).unwrap_or_default(); + return Some(format!("{agent}{}", c["body"].as_str().unwrap_or(""))); + } + } + crate::tools::BUILTIN.iter().find(|(p, _)| *p == format!("commands/{name}.md")).map(|(_, c)| c.to_string()) + } + + fn builtin_slash() -> Vec<(&'static str, &'static str)> { + vec![ + ("help", "keys and commands"), + ("model", "choose the model"), + ("mode", "choose the mode (build, plan, craft …)"), + ("permissions", "permission mode (ask, auto, edits, everything)"), + ("effort", "thinking effort for the next message"), + ("new", "start a new session"), + ("resume", "continue an earlier session"), + ("compact", "summarize the conversation to free context"), + ("costs", "costs of the last 30 days"), + ("theme", "colour theme"), + ("lang", "language (en/de)"), + ("setup", "add model providers or sign in to a server"), + ("memory", "show the project memory"), + ("jobs", "automations on the server"), + ("quit", "exit"), + ] + } + + /// Built-in slash commands; false = not a built-in. + fn slash(&mut self, text: &str) -> bool { + let (name, arg) = text[1..].split_once(' ').map(|(a, b)| (a, b.trim())).unwrap_or((&text[1..], "")); + match name { + "help" => { + let mut body = String::from("Keys\n Enter send · Shift/Alt+Enter new line · Esc interrupt · Shift+Tab mode\n ↑/↓ history · Ctrl+V paste image · PgUp/PgDn or mouse wheel scroll · click a tool to expand\n Ctrl+O expand/collapse all tools · Ctrl+C clear / quit\n\nCommands\n"); + for (n, d) in Self::builtin_slash() { + body.push_str(&format!(" /{n:<12} {d}\n")); + } + for (n, d) in self.commands() { + body.push_str(&format!(" /{n:<12} {d}\n")); + } + self.dialog = Dialog::Text { title: t("help").into(), body }; + } + "model" => self.open_picker(PickKind::Model), + "mode" => { + if arg.is_empty() { + self.open_picker(PickKind::Mode) + } else { + self.set_mode(arg) + } + } + "permissions" => self.open_picker(PickKind::Permission), + "effort" => self.open_picker(PickKind::Effort), + "theme" => self.open_picker(PickKind::Theme), + "lang" => self.open_picker(PickKind::Lang), + "resume" | "sessions" => self.open_picker(PickKind::Session), + "new" | "clear" => { + let _ = self.cmd.send(Cmd::New); + self.session = LocalSession::new(&self.session.cwd, &self.session.model, &self.session.mode, &self.session.permission_mode); + self.items.clear(); + self.todos = Value::Null; + self.context = 0; + } + "compact" => { + let c = CancellationToken::new(); + self.cancel = Some(c.clone()); + self.busy = true; + self.busy_since = Instant::now(); + let _ = self.cmd.send(Cmd::Compact(c)); + } + "costs" => { + let since = codecraft_core::types::now_ms() - 30 * 86_400_000; + let mut body = String::from("Last 30 days (local log)\n\n"); + let mut total = 0.0; + for (m, n, i, o, c) in crate::stats::summary(since) { + body.push_str(&format!(" {m:<44} {n:>5} req {i:>10} in {o:>9} out {c:>9.4}\n")); + total += c; + } + body.push_str(&format!("\n total {total:.4} {}\n this session {:.4}", self.env.catalog.currency, self.session.cost)); + self.dialog = Dialog::Text { title: "Costs".into(), body }; + } + "memory" => { + let body = std::fs::read_to_string(PathBuf::from(&self.session.cwd).join(".codecraft").join("memory.md")).unwrap_or_else(|_| "(no project memory yet – the agent saves facts with the remember tool)".into()); + self.dialog = Dialog::Text { title: "Project memory".into(), body }; + } + "jobs" => { + let Some(server) = self.env.server.clone() else { + self.items.push(Item::Error("not signed in to a server".into())); + return true; + }; + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let _ = tx.send(server.get("/jobs").await); + }); + self.pending_jobs = Some(rx); + } + "setup" | "login" => self.open_setup(), + "quit" | "exit" => self.quit = true, + _ => return false, + } + true + } + + fn set_mode(&mut self, name: &str) { + if self.env.modes.iter().any(|m| m.name == name && m.primary) { + self.session.mode = name.into(); + let _ = self.cmd.send(Cmd::SetMode(name.into())); + } + } + + fn cycle_mode(&mut self) { + let modes: Vec = self.env.modes.iter().filter(|m| m.primary).map(|m| m.name.clone()).collect(); + if modes.is_empty() { + return; + } + let i = modes.iter().position(|m| *m == self.session.mode).map(|i| (i + 1) % modes.len()).unwrap_or(0); + let m = modes[i].clone(); + self.set_mode(&m); + } + + fn open_picker(&mut self, kind: PickKind) { + let (title, items): (String, Vec<(String, String, String)>) = match kind { + PickKind::Model => (t("choose_model").into(), self.env.catalog.models.iter().map(|m| (m.spec.clone(), m.label.clone(), m.config.context.map(|c| format!("{}k ctx", c / 1000)).unwrap_or_default())).collect()), + PickKind::Mode => (t("choose_mode").into(), self.env.modes.iter().filter(|m| m.primary).map(|m| (m.name.clone(), m.name.clone(), m.description.clone())).collect()), + PickKind::Permission => ( + "Permissions".into(), + vec![ + ("default".into(), "ask".into(), "ask before changes and commands".into()), + ("auto".into(), "automatic".into(), "asks once for larger plans, safe things pass".into()), + ("accept_edits".into(), "accept edits".into(), "file changes pass, commands ask".into()), + ("bypass".into(), "allow everything".into(), "no questions (careful – runs on your machine)".into()), + ], + ), + PickKind::Effort => ("Effort".into(), ["low", "medium", "high", "max"].iter().map(|e| (e.to_string(), e.to_string(), "next message only".into())).collect()), + PickKind::Theme => (t("choose_theme").into(), theme::names().into_iter().flat_map(|n| ["auto", "dark", "light"].map(|m| (format!("{n}:{m}"), format!("{n} ({m})"), String::new()))).collect()), + PickKind::Lang => ("Language".into(), vec![("en".into(), "English".into(), String::new()), ("de".into(), "Deutsch".into(), String::new())]), + PickKind::Session => ( + t("choose_session").into(), + crate::session::list(None) + .into_iter() + .take(100) + .map(|s| { + let when = chrono::DateTime::from_timestamp_millis(s.updated_at).map(|d| d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); + (s.id.clone(), if s.title.is_empty() { "(untitled)".into() } else { s.title.clone() }, format!("{when} · {}", s.cwd)) + }) + .collect(), + ), + }; + self.dialog = Dialog::Picker(Picker { kind, title, items, filter: String::new(), sel: 0 }); + } + + fn pick(&mut self, kind: PickKind, key: String) { + match kind { + PickKind::Model => { + self.session.model = key.clone(); + let _ = self.cmd.send(Cmd::SetModel(key.clone())); + let mut cfg = crate::conf::Config::load().unwrap_or_default(); + cfg.default_model = Some(key); + let _ = cfg.save(); + } + PickKind::Mode => self.set_mode(&key), + PickKind::Permission => { + self.session.permission_mode = key.clone(); + let _ = self.cmd.send(Cmd::SetPermission(key)); + } + PickKind::Effort => self.effort = Some(key), + PickKind::Theme => { + let (base, mode) = key.split_once(':').unwrap_or((&key, "auto")); + theme::set(Theme::load(base, mode)); + let mut cfg = crate::conf::Config::load().unwrap_or_default(); + cfg.theme = Some(base.into()); + cfg.theme_mode = Some(mode.into()); + let _ = cfg.save(); + } + PickKind::Lang => { + crate::i18n::set_lang(&key); + let mut cfg = crate::conf::Config::load().unwrap_or_default(); + cfg.lang = Some(key); + let _ = cfg.save(); + } + PickKind::Session => { + if let Ok(s) = LocalSession::load(&key) { + self.session = s.clone(); + self.todos = s.todos.clone(); + self.context = s.context_tokens; + self.rebuild_items(); + let _ = self.cmd.send(Cmd::Load(Box::new(s))); + } + } + } + } + + // --- setup wizard --------------------------------------------------------------- + + fn open_setup(&mut self) { + self.dialog = Dialog::Setup(SetupStep::Choose { sel: 0, detected: vec![], note: String::new() }); + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let _ = tx.send(crate::models::detect_local().await); + }); + self.pending_detect = Some(rx); + } +} + + +// --- input --------------------------------------------------------------------------- + +fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) -> codecraft_core::config::ProviderConfig { + codecraft_core::config::ProviderConfig { + kind: preset.kind, + base_url: base_url.trim().to_string(), + api_key: key.trim().to_string(), + auth_header: None, + auth_prefix: None, + headers: Default::default(), + models: vec![], + enabled: true, + max_concurrency: None, + monthly_budget: None, + admin_key: String::new(), + } +} + +impl App { + fn on_key(&mut self, k: KeyEvent) { + if k.kind == KeyEventKind::Release { + return; + } + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + if !matches!(self.dialog, Dialog::None) { + self.on_dialog_key(k); + return; + } + // Completion popup. + if let Some(c) = &mut self.completion { + match k.code { + KeyCode::Up => { + c.sel = c.sel.saturating_sub(1); + return; + } + KeyCode::Down => { + c.sel = (c.sel + 1).min(c.items.len().saturating_sub(1)); + return; + } + KeyCode::Tab | KeyCode::Enter if !c.items.is_empty() => { + let (val, _) = c.items[c.sel].clone(); + let start = c.start; + let enter = k.code == KeyCode::Enter; + self.completion = None; + // Enter on a slash command runs it right away. + if enter && val.starts_with('/') { + self.editor.replace_word(start, &val); + self.submit(); + } else { + self.editor.replace_word(start, &format!("{val} ")); + } + return; + } + KeyCode::Esc => { + self.completion = None; + return; + } + _ => {} + } + } + match (k.code, ctrl) { + (KeyCode::Char('c'), true) => { + if self.busy { + if let Some(c) = &self.cancel { + c.cancel(); + } + self.items.push(Item::Info(t("aborted").into())); + } else if !self.editor.is_empty() { + self.editor.clear(); + } else if self.quit_armed.is_some_and(|t| t.elapsed() < Duration::from_secs(2)) { + self.quit = true; + } else { + self.quit_armed = Some(Instant::now()); + self.status = t("quit_again").into(); + } + } + (KeyCode::Char('d'), true) if self.editor.is_empty() => self.quit = true, + (KeyCode::Esc, _) => { + if self.busy { + if let Some(c) = &self.cancel { + c.cancel(); + } + self.queue.clear(); + self.items.push(Item::Info(t("aborted").into())); + } + } + (KeyCode::Enter, _) if k.modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => self.editor.newline(), + (KeyCode::Char('j'), true) => self.editor.newline(), + (KeyCode::Enter, _) => { + // A trailing backslash continues on the next line. + if self.editor.lines[self.editor.row].ends_with('\\') { + self.editor.backspace(); + self.editor.newline(); + } else { + self.submit(); + } + } + (KeyCode::BackTab, _) => self.cycle_mode(), + (KeyCode::Tab, _) => self.update_completion(true), + (KeyCode::Backspace, _) => self.editor.backspace(), + (KeyCode::Delete, _) => self.editor.delete(), + (KeyCode::Char('w'), true) => self.editor.delete_word(), + (KeyCode::Char('u'), true) => self.editor.clear(), + (KeyCode::Char('a'), true) | (KeyCode::Home, _) => self.editor.home(), + (KeyCode::Char('e'), true) | (KeyCode::End, _) => self.editor.end(), + (KeyCode::Char('o'), true) => { + let open = !self.items.iter().any(|i| matches!(i, Item::Tool { open: true, .. })); + for i in &mut self.items { + if let Item::Tool { open: o, .. } | Item::Thinking(_, o) = i { + *o = open; + } + } + } + (KeyCode::Char('v'), true) => self.paste_image(), + (KeyCode::Left, _) => self.editor.left(), + (KeyCode::Right, _) => self.editor.right(), + (KeyCode::Up, _) => self.editor.up(), + (KeyCode::Down, _) => self.editor.down(), + (KeyCode::PageUp, _) => self.scroll += 10, + (KeyCode::PageDown, _) => self.scroll = self.scroll.saturating_sub(10), + (KeyCode::Char(c), false) => self.editor.insert(&c.to_string()), + _ => {} + } + self.update_completion(false); + } + + /// Image from the clipboard (Wayland, X11, macOS) via the usual helper programs. + fn paste_image(&mut self) { + let tries: &[(&str, &[&str])] = &[("wl-paste", &["--type", "image/png"]), ("xclip", &["-selection", "clipboard", "-t", "image/png", "-o"]), ("pngpaste", &["-"])]; + for (cmd, args) in tries { + if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() { + if out.status.success() && out.stdout.starts_with(b"\x89PNG") { + self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) }); + self.status = format!("image {} attached", self.images.len()); + return; + } + } + } + self.status = "no image in the clipboard (needs wl-paste, xclip or pngpaste) – or write @path/to/image.png".into(); + } + + fn update_completion(&mut self, force: bool) { + let (start, word) = self.editor.current_word(); + if self.editor.row == 0 && start == 0 && word.starts_with('/') { + let q = &word[1..]; + let mut items: Vec<(String, String)> = Self::builtin_slash().into_iter().map(|(n, d)| (format!("/{n}"), d.to_string())).collect(); + items.extend(self.commands().into_iter().map(|(n, d)| (format!("/{n}"), d))); + items.retain(|(n, _)| n[1..].starts_with(q)); + self.completion = if items.is_empty() { None } else { Some(Completion { start, items: items.into_iter().take(12).collect(), sel: 0 }) }; + } else if let Some(q) = word.strip_prefix('@') { + if q.is_empty() && !force { + self.completion = None; + return; + } + let ql = q.to_lowercase(); + let cwd = PathBuf::from(&self.session.cwd); + let mut hits: Vec<(usize, String)> = Vec::new(); + for e in ignore::WalkBuilder::new(&cwd).hidden(true).build().flatten().take(20_000) { + if e.depth() == 0 { + continue; + } + let rel = crate::tools::rel(&cwd, e.path()); + let rl = rel.to_lowercase(); + let name = e.file_name().to_string_lossy().to_lowercase(); + let rank = if name.starts_with(&ql) { 0 } else if name.contains(&ql) { 1 } else if rl.contains(&ql) { 2 } else { continue }; + let dir = e.file_type().is_some_and(|t| t.is_dir()); + hits.push((rank * 1000 + rel.len(), format!("{rel}{}", if dir { "/" } else { "" }))); + } + hits.sort(); + self.completion = if hits.is_empty() { None } else { Some(Completion { start, items: hits.into_iter().take(10).map(|(_, r)| (format!("@{r}"), String::new())).collect(), sel: 0 }) }; + } else { + self.completion = None; + } + } + + fn on_dialog_key(&mut self, k: KeyEvent) { + let dialog = std::mem::replace(&mut self.dialog, Dialog::None); + self.dialog = match dialog { + Dialog::Text { title, body } => match k.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => Dialog::None, + _ => Dialog::Text { title, body }, + }, + Dialog::Permission { tool, title, diff, input, mut reply, feedback } => { + if let Some(mut fb) = feedback { + match k.code { + KeyCode::Enter => { + if let Some(r) = reply.take() { + let _ = r.send(Reply::Reject(Some(fb))); + } + Dialog::None + } + KeyCode::Esc => Dialog::Permission { tool, title, diff, input, reply, feedback: None }, + KeyCode::Backspace => { + fb.pop(); + Dialog::Permission { tool, title, diff, input, reply, feedback: Some(fb) } + } + KeyCode::Char(c) => { + fb.push(c); + Dialog::Permission { tool, title, diff, input, reply, feedback: Some(fb) } + } + _ => Dialog::Permission { tool, title, diff, input, reply, feedback: Some(fb) }, + } + } else { + let answer = match k.code { + KeyCode::Char('y') | KeyCode::Char('j') | KeyCode::Enter => Some(Reply::Once), + KeyCode::Char('a') => Some(Reply::Always), + KeyCode::Char('n') | KeyCode::Esc => Some(Reply::Reject(None)), + KeyCode::Char('f') => return self.dialog = Dialog::Permission { tool, title, diff, input, reply, feedback: Some(String::new()) }, + _ => None, + }; + match answer { + Some(a) => { + if let Some(r) = reply.take() { + let _ = r.send(a); + } + Dialog::None + } + None => Dialog::Permission { tool, title, diff, input, reply, feedback }, + } + } + } + Dialog::Question { questions, mut idx, mut sel, mut custom, mut answers, mut reply } => { + let q = questions[idx].clone(); + let n = q.options.len(); + let digit = match k.code { + KeyCode::Char(c) if custom.is_empty() && c.is_ascii_digit() && (1..=n).contains(&(c as usize - '0' as usize)) => Some(c as usize - '1' as usize), + _ => None, + }; + let next = |value: String, answers: &mut serde_json::Map| { + answers.insert(q.question.clone(), Value::String(value)); + }; + match k.code { + KeyCode::Esc => { + if let Some(r) = reply.take() { + let _ = r.send(None); + } + Dialog::None + } + KeyCode::Up => { + sel = sel.saturating_sub(1); + Dialog::Question { questions, idx, sel, custom, answers, reply } + } + KeyCode::Down => { + sel = (sel + 1).min(n.saturating_sub(1)); + Dialog::Question { questions, idx, sel, custom, answers, reply } + } + KeyCode::Backspace => { + custom.pop(); + Dialog::Question { questions, idx, sel, custom, answers, reply } + } + KeyCode::Enter | KeyCode::Char(_) if matches!(k.code, KeyCode::Enter) || digit.is_some() => { + let value = if !custom.trim().is_empty() { + custom.trim().to_string() + } else { + q.options.get(digit.unwrap_or(sel)).map(|o| o.label.clone()).unwrap_or_default() + }; + next(value, &mut answers); + if idx + 1 < questions.len() { + idx += 1; + Dialog::Question { questions, idx, sel: 0, custom: String::new(), answers, reply } + } else { + if let Some(r) = reply.take() { + let _ = r.send(Some(answers)); + } + Dialog::None + } + } + KeyCode::Char(c) => { + custom.push(c); + Dialog::Question { questions, idx, sel, custom, answers, reply } + } + _ => Dialog::Question { questions, idx, sel, custom, answers, reply }, + } + } + Dialog::Picker(mut p) => match k.code { + KeyCode::Esc => Dialog::None, + KeyCode::Up => { + p.sel = p.sel.saturating_sub(1); + Dialog::Picker(p) + } + KeyCode::Down => { + p.sel = (p.sel + 1).min(p.visible().len().saturating_sub(1)); + Dialog::Picker(p) + } + KeyCode::Enter => { + if let Some((key, _, _)) = p.visible().get(p.sel).map(|x| (*x).clone()) { + self.pick(p.kind, key); + } + if matches!(self.dialog, Dialog::None) { Dialog::None } else { std::mem::replace(&mut self.dialog, Dialog::None) } + } + KeyCode::Backspace => { + p.filter.pop(); + p.sel = 0; + Dialog::Picker(p) + } + KeyCode::Char(c) => { + p.filter.push(c); + p.sel = 0; + Dialog::Picker(p) + } + _ => Dialog::Picker(p), + }, + Dialog::Setup(step) => self.on_setup_key(step, k), + Dialog::None => Dialog::None, + }; + } + + fn on_setup_key(&mut self, step: SetupStep, k: KeyEvent) -> Dialog { + let presets = crate::models::presets(); + match step { + SetupStep::Choose { mut sel, detected, note } => { + let n = presets.len() + 2; + match k.code { + KeyCode::Esc => { + self.finish_setup(); + Dialog::None + } + KeyCode::Up => { + sel = sel.saturating_sub(1); + Dialog::Setup(SetupStep::Choose { sel, detected, note }) + } + KeyCode::Down => { + sel = (sel + 1).min(n - 1); + Dialog::Setup(SetupStep::Choose { sel, detected, note }) + } + KeyCode::Enter if sel == 0 => { + let url = crate::conf::Config::load().ok().and_then(|c| c.server.map(|s| s.url)).unwrap_or_else(|| "https://".into()); + Dialog::Setup(SetupStep::Server { fields: [url, String::new(), String::new()], focus: 0, error: String::new() }) + } + KeyCode::Enter if sel == n - 1 => { + self.finish_setup(); + Dialog::None + } + KeyCode::Enter => { + let p = &presets[sel - 1]; + Dialog::Setup(SetupStep::Provider { preset: sel - 1, fields: [p.id.to_string(), p.base_url.to_string(), String::new()], focus: if p.needs_key { 2 } else { 1 }, error: String::new() }) + } + _ => Dialog::Setup(SetupStep::Choose { sel, detected, note }), + } + } + SetupStep::Server { mut fields, mut focus, error } => match k.code { + KeyCode::Esc => Dialog::Setup(SetupStep::Choose { sel: 0, detected: vec![], note: String::new() }), + KeyCode::Tab | KeyCode::Down => { + focus = (focus + 1) % 3; + Dialog::Setup(SetupStep::Server { fields, focus, error }) + } + KeyCode::BackTab | KeyCode::Up => { + focus = (focus + 2) % 3; + Dialog::Setup(SetupStep::Server { fields, focus, error }) + } + KeyCode::Enter if focus < 2 => { + focus += 1; + Dialog::Setup(SetupStep::Server { fields, focus, error }) + } + KeyCode::Enter => { + let [url, user, pass] = fields.clone(); + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let r = async { + let login = crate::server::login(&url, &user, &pass).await?; + let mut cfg = crate::conf::Config::load().unwrap_or_default(); + let msg = format!("signed in to {} as {}", login.url, login.username); + cfg.server = Some(login); + cfg.save()?; + Ok(msg) + } + .await; + let _ = tx.send(r); + }); + self.pending_setup = Some(rx); + Dialog::Setup(SetupStep::Server { fields, focus, error: "…".into() }) + } + KeyCode::Backspace => { + fields[focus].pop(); + Dialog::Setup(SetupStep::Server { fields, focus, error }) + } + KeyCode::Char(c) => { + fields[focus].push(c); + Dialog::Setup(SetupStep::Server { fields, focus, error }) + } + _ => Dialog::Setup(SetupStep::Server { fields, focus, error }), + }, + SetupStep::Provider { preset, mut fields, mut focus, error } => match k.code { + KeyCode::Esc => Dialog::Setup(SetupStep::Choose { sel: preset + 1, detected: vec![], note: String::new() }), + KeyCode::Tab | KeyCode::Down => { + focus = (focus + 1) % 3; + Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) + } + KeyCode::BackTab | KeyCode::Up => { + focus = (focus + 2) % 3; + Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) + } + KeyCode::Enter => { + let p = &presets[preset]; + let pc = provider_config(p, &fields[1], &fields[2]); + let name: String = fields[0].trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').collect(); + if name.is_empty() { + return Dialog::Setup(SetupStep::Provider { preset, fields, focus, error: "name missing".into() }); + } + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let r = async { + let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), &pc); + let models = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await.map_err(|_| anyhow::anyhow!("the endpoint did not answer"))??; + let mut cfg = crate::conf::Config::load().unwrap_or_default(); + cfg.providers.insert(name.clone(), pc); + cfg.save()?; + Ok(format!("{name}: {} {}", models.len(), t("models_found"))) + } + .await; + let _ = tx.send(r); + }); + self.pending_setup = Some(rx); + Dialog::Setup(SetupStep::Provider { preset, fields, focus, error: "…".into() }) + } + KeyCode::Backspace => { + fields[focus].pop(); + Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) + } + KeyCode::Char(c) => { + fields[focus].push(c); + Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) + } + _ => Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }), + }, + } + } + + /// Reload configuration, models and modes after the setup. + fn finish_setup(&mut self) { + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + let _ = tx.send(crate::build_env().await); + }); + self.pending_env = Some(rx); + self.status = "loading models…".into(); + } + + fn poll_pending(&mut self) { + if let Some(rx) = &mut self.pending_detect { + if let Ok(found) = rx.try_recv() { + self.pending_detect = None; + if let Dialog::Setup(SetupStep::Choose { detected, .. }) = &mut self.dialog { + *detected = found; + } + } + } + if let Some(rx) = &mut self.pending_setup { + if let Ok(res) = rx.try_recv() { + self.pending_setup = None; + match res { + Ok(msg) => self.dialog = Dialog::Setup(SetupStep::Choose { sel: crate::models::presets().len() + 1, detected: vec![], note: msg }), + Err(e) => match &mut self.dialog { + Dialog::Setup(SetupStep::Server { error, .. }) | Dialog::Setup(SetupStep::Provider { error, .. }) => *error = format!("{e:#}"), + _ => self.items.push(Item::Error(format!("{e:#}"))), + }, + } + } + } + if let Some(rx) = &mut self.pending_env { + if let Ok(res) = rx.try_recv() { + self.pending_env = None; + match res { + Ok(env) => { + let env = Arc::new(env); + if env.catalog.find(&self.session.model).is_none() { + if let Some(m) = env.catalog.models.first() { + self.session.model = m.spec.clone(); + let _ = self.cmd.send(Cmd::SetModel(m.spec.clone())); + } + } + self.env = env.clone(); + let _ = self.cmd.send(Cmd::SetEnv(env)); + self.status = format!("{} models available", self.env.catalog.models.len()); + } + Err(e) => self.items.push(Item::Error(format!("{e:#}"))), + } + } + } + if let Some(rx) = &mut self.pending_jobs { + if let Ok(res) = rx.try_recv() { + self.pending_jobs = None; + let body = match res { + Ok(v) => { + let mut b = String::new(); + for j in v.as_array().into_iter().flatten() { + b.push_str(&format!( + "{} {:<32} {:<14} last: {}\n", + if j["enabled"] == true { "on " } else { "off" }, + j["spec"]["name"].as_str().unwrap_or("?"), + j["spec"]["schedule"].as_str().unwrap_or("?"), + j["last_status"].as_str().unwrap_or("-") + )); + } + if b.is_empty() { "(no automations)".into() } else { b } + } + Err(e) => format!("{e:#}"), + }; + self.dialog = Dialog::Text { title: "Automations (server)".into(), body }; + } + } + } + + fn on_mouse(&mut self, m: crossterm::event::MouseEvent) { + match m.kind { + MouseEventKind::ScrollUp => self.scroll += 3, + MouseEventKind::ScrollDown => self.scroll = self.scroll.saturating_sub(3), + MouseEventKind::Down(MouseButton::Left) => { + let a = self.history_area; + if m.row >= a.y && m.row < a.y + a.height && m.column >= a.x && m.column < a.x + a.width { + if let Some(Some(idx)) = self.hit.get((m.row - a.y) as usize) { + match &mut self.items[*idx] { + Item::Tool { open, .. } | Item::Thinking(_, open) => *open = !*open, + _ => {} + } + } + } + } + _ => {} + } + } + + // --- rendering ---------------------------------------------------------------------- + + fn item_lines(&self, item: &Item, width: usize, th: &Theme) -> Vec> { + let mut out = Vec::new(); + match item { + Item::User(text, imgs) => { + out.push(Line::default()); + for (i, l) in wrap(text, width.saturating_sub(2)).into_iter().enumerate() { + out.push(Line::from(vec![Span::styled(if i == 0 { "› " } else { " " }, Style::default().fg(th.accent)), Span::styled(l, Style::default().fg(th.fg0).bg(th.bg1))])); + } + if *imgs > 0 { + out.push(Line::from(Span::styled(format!(" [{imgs} image(s)]"), Style::default().fg(th.muted)))); + } + } + Item::Assistant(text) => { + out.push(Line::default()); + out.extend(markdown(text.trim(), width, th)); + } + Item::Thinking(text, open) => { + let words = text.split_whitespace().count(); + out.push(Line::from(Span::styled(format!("{} {} ({words} words)", if *open { "▾" } else { "▸" }, t("thinking")), Style::default().fg(th.muted)))); + if *open { + for l in wrap(text.trim(), width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {l}"), Style::default().fg(th.fg3).add_modifier(Modifier::ITALIC)))); + } + } + } + Item::Tool { name, title, state, output, diff, open, .. } => { + let (icon, col) = match state { + ToolState::Running => (SPINNER[self.tick % SPINNER.len()], th.accent), + ToolState::Ok => ("✓", th.green), + ToolState::Err => ("✗", th.red), + }; + let title: String = title.lines().next().unwrap_or("").chars().take(width.saturating_sub(name.len() + 6)).collect(); + out.push(Line::from(vec![Span::styled(format!("{icon} "), Style::default().fg(col)), Span::styled(name.clone(), Style::default().fg(th.accent2).add_modifier(Modifier::BOLD)), Span::styled(format!(" {title}"), Style::default().fg(th.fg2))])); + if let Some(d) = diff { + out.extend(diff_lines(d, if *open { 400 } else { 12 }, th)); + } else if *open && !output.is_empty() { + for l in wrap(output, width.saturating_sub(4)).into_iter().take(80) { + out.push(Line::from(Span::styled(format!(" {l}"), Style::default().fg(th.fg3)))); + } + } else if matches!(state, ToolState::Err) { + let first: String = output.lines().next().unwrap_or("").chars().take(width.saturating_sub(4)).collect(); + out.push(Line::from(Span::styled(format!(" {first}"), Style::default().fg(th.red)))); + } + } + Item::Info(s) => out.push(Line::from(Span::styled(format!(" {s}"), Style::default().fg(th.muted)))), + Item::Error(s) => { + for l in wrap(s, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!("! {l}"), Style::default().fg(th.red)))); + } + } + } + out + } + + fn draw(&mut self, f: &mut Frame) { + let th = theme::get(); + let area = f.area(); + f.render_widget(Block::default().style(Style::default().bg(th.bg0).fg(th.fg1)), area); + let input_h = (self.editor.lines.len() as u16).clamp(1, 8) + 2; + let todo_items: Vec<&Value> = self.todos.as_array().map(|a| a.iter().collect()).unwrap_or_default(); + let todo_h = if todo_items.iter().any(|t| t["status"] != "completed") { (todo_items.len() as u16).min(6) } else { 0 }; + let [head, hist, todos, act, input, status] = Layout::vertical([Constraint::Length(1), Constraint::Min(3), Constraint::Length(todo_h), Constraint::Length(1), Constraint::Length(input_h), Constraint::Length(1)]).areas(area); + + // Header. + let cwd = self.session.cwd.replace(&crate::conf::home().display().to_string(), "~"); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" CodeCraft ", Style::default().fg(th.on_accent).bg(th.accent).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {cwd}"), Style::default().fg(th.fg2)), + Span::styled(if self.session.title.is_empty() { String::new() } else { format!(" · {}", self.session.title) }, Style::default().fg(th.muted)), + ])), + head, + ); + + // History. + let width = hist.width.saturating_sub(2) as usize; + let mut lines: Vec = Vec::new(); + let mut owners: Vec> = Vec::new(); + if self.items.is_empty() { + lines.push(Line::default()); + lines.push(Line::from(Span::styled(format!(" {}", t("welcome")), Style::default().fg(th.accent).add_modifier(Modifier::BOLD)))); + lines.push(Line::from(Span::styled(format!(" {} · {} · /help", self.model_label(), self.session.mode), Style::default().fg(th.muted)))); + owners.extend([None, None, None]); + } + for (i, item) in self.items.iter().enumerate() { + let l = self.item_lines(item, width, &th); + owners.extend(std::iter::repeat_n(Some(i), l.len())); + lines.extend(l); + } + let h = hist.height as usize; + let max_scroll = lines.len().saturating_sub(h); + self.scroll = self.scroll.min(max_scroll); + let start = lines.len().saturating_sub(h + self.scroll); + let shown: Vec = lines[start..(start + h).min(lines.len())].to_vec(); + self.hit = owners[start..(start + h).min(owners.len())].to_vec(); + self.history_area = Rect { x: hist.x + 1, ..hist }; + f.render_widget(Paragraph::new(shown), Rect { x: hist.x + 1, width: hist.width.saturating_sub(1), ..hist }); + if self.scroll > 0 { + let note = format!(" ↓ {} more ", self.scroll); + let w = note.chars().count() as u16; + f.render_widget(Paragraph::new(Span::styled(note, Style::default().fg(th.on_accent).bg(th.accent))), Rect { x: hist.x + hist.width.saturating_sub(w + 1), y: hist.y + hist.height - 1, width: w, height: 1 }); + } + + // Todos. + if todo_h > 0 { + let tl: Vec = todo_items + .iter() + .take(6) + .map(|t| { + let (mark, col) = match t["status"].as_str() { + Some("completed") => ("[x]", th.green), + Some("in_progress") => ("[>]", th.accent), + _ => ("[ ]", th.muted), + }; + Line::from(vec![Span::styled(format!(" {mark} "), Style::default().fg(col)), Span::styled(t["content"].as_str().unwrap_or("").to_string(), Style::default().fg(th.fg2))]) + }) + .collect(); + f.render_widget(Paragraph::new(tl), todos); + } + + // Activity line. + let activity = if self.busy { + let secs = self.busy_since.elapsed().as_secs(); + Line::from(vec![ + Span::styled(format!(" {} ", SPINNER[self.tick % SPINNER.len()]), Style::default().fg(th.accent2)), + Span::styled(format!("{}…", t("working")), Style::default().fg(th.accent2)), + Span::styled(format!(" ({secs}s · ~{} tokens) · {}", self.step_chars / 4, t("interrupt")), Style::default().fg(th.muted)), + Span::styled(if self.queue.is_empty() { String::new() } else { format!(" · {} {}", self.queue.len(), t("queued")) }, Style::default().fg(th.muted)), + ]) + } else { + Line::from(Span::styled(format!(" {}", self.status), Style::default().fg(th.muted))) + }; + f.render_widget(Paragraph::new(activity), act); + + // Input. + let mode_col = match self.session.mode.as_str() { + "plan" => th.blue, + "craft" => th.purple, + _ => th.accent, + }; + let mut title = vec![Span::styled(format!(" {} ", self.session.mode), Style::default().fg(th.on_accent).bg(mode_col).add_modifier(Modifier::BOLD))]; + if !self.images.is_empty() { + title.push(Span::styled(format!(" {} image(s) ", self.images.len()), Style::default().fg(th.accent2))); + } + if let Some(e) = &self.effort { + title.push(Span::styled(format!(" effort {e} "), Style::default().fg(th.accent2))); + } + let block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(if self.busy { th.bg3 } else { mode_col })).title(Line::from(title)); + let inner = block.inner(input); + f.render_widget(block, input); + let visible = inner.height as usize; + let first = self.editor.row.saturating_sub(visible.saturating_sub(1)); + let text: Vec = if self.editor.is_empty() { + vec![Line::from(Span::styled(if self.busy { t("busy_placeholder") } else { t("placeholder") }, Style::default().fg(th.muted)))] + } else { + self.editor.lines[first..].iter().take(visible).map(|l| Line::from(Span::styled(l.clone(), Style::default().fg(th.fg0)))).collect() + }; + let scroll_x = self.editor.cursor_x().saturating_sub(inner.width.saturating_sub(1) as usize) as u16; + f.render_widget(Paragraph::new(text).scroll((0, scroll_x)), inner); + if matches!(self.dialog, Dialog::None) { + f.set_cursor_position((inner.x + self.editor.cursor_x() as u16 - scroll_x, inner.y + (self.editor.row - first) as u16)); + } + + // Status line. + let ctx_pct = self.env.catalog.find(&self.session.model).and_then(|m| m.config.context).filter(|c| *c > 0).map(|c| format!("{} {}%", t("context"), self.context * 100 / c)).unwrap_or_default(); + let cost = if self.session.cost > 0.0 { format!(" · {:.3} {}", self.session.cost, self.env.catalog.currency) } else { String::new() }; + let left = format!(" {} · {} ", self.model_label(), self.session.permission_mode); + let right = format!("{ctx_pct}{cost} · Shift+Tab {} · /help ", t("mode")); + let pad = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count()); + f.render_widget(Paragraph::new(Line::from(vec![Span::styled(left, Style::default().fg(th.fg3)), Span::raw(" ".repeat(pad)), Span::styled(right, Style::default().fg(th.muted))])).style(Style::default().bg(th.bg1)), status); + + // Completion popup. + if let Some(c) = &self.completion { + let h = c.items.len() as u16 + 2; + let w = (c.items.iter().map(|(a, b)| a.chars().count() + b.chars().count() + 4).max().unwrap_or(20) as u16 + 2).min(area.width - 4); + let r = Rect { x: input.x + 1, y: input.y.saturating_sub(h), width: w, height: h }; + f.render_widget(Clear, r); + let lines: Vec = c + .items + .iter() + .enumerate() + .map(|(i, (a, b))| { + let st = if i == c.sel { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(th.fg1) }; + Line::from(vec![Span::styled(format!(" {a} "), st), Span::styled(format!(" {b}"), Style::default().fg(th.muted))]) + }) + .collect(); + f.render_widget(Paragraph::new(lines).block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(th.bg3))).style(Style::default().bg(th.bg1)), r); + } + + self.draw_dialog(f, area, &th); + } + + fn draw_dialog(&self, f: &mut Frame, area: Rect, th: &Theme) { + let boxed = |f: &mut Frame, title: &str, w: u16, h: u16, color| -> Rect { + let w = w.min(area.width.saturating_sub(4)); + let h = h.min(area.height.saturating_sub(2)); + let r = Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, width: w, height: h }; + f.render_widget(Clear, r); + let b = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(color)).title(Span::styled(format!(" {title} "), Style::default().fg(color).add_modifier(Modifier::BOLD))).style(Style::default().bg(th.bg1).fg(th.fg1)); + let inner = b.inner(r); + f.render_widget(b, r); + inner + }; + match &self.dialog { + Dialog::None => {} + Dialog::Text { title, body } => { + let lines = body.lines().count() as u16 + 3; + let inner = boxed(f, title, 100, lines, th.accent); + f.render_widget(Paragraph::new(body.clone()).style(Style::default().fg(th.fg1)), inner); + } + Dialog::Permission { tool, title, diff, input, feedback, .. } => { + let mut lines = vec![Line::from(vec![Span::styled(format!("{tool} "), Style::default().fg(th.accent2).add_modifier(Modifier::BOLD)), Span::raw(title.clone())])]; + if tool == "bash" { + for l in wrap(&format!("$ {}", input["command"].as_str().unwrap_or("")), 94) { + lines.push(Line::from(Span::styled(l, Style::default().fg(th.fg0)))); + } + } + if let Some(d) = diff { + lines.extend(diff_lines(d, 30, th)); + } else if tool != "bash" { + for l in wrap(&serde_json::to_string_pretty(input).unwrap_or_default(), 94).into_iter().take(16) { + lines.push(Line::from(Span::styled(l, Style::default().fg(th.fg3)))); + } + } + lines.push(Line::default()); + match feedback { + Some(fb) => { + lines.push(Line::from(Span::styled(t("feedback"), Style::default().fg(th.fg2)))); + lines.push(Line::from(Span::styled(format!("> {fb}_"), Style::default().fg(th.fg0)))); + } + None => lines.push(Line::from(Span::styled(t("perm_keys"), Style::default().fg(th.accent)))), + } + let inner = boxed(f, t("permission"), 100, lines.len() as u16 + 2, th.accent); + f.render_widget(Paragraph::new(lines), inner); + } + Dialog::Question { questions, idx, sel, custom, .. } => { + let q = &questions[*idx]; + let mut lines = vec![Line::from(Span::styled(q.question.clone(), Style::default().fg(th.fg0).add_modifier(Modifier::BOLD))), Line::default()]; + for (i, o) in q.options.iter().enumerate() { + let st = if i == *sel && custom.is_empty() { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(th.fg1) }; + lines.push(Line::from(vec![Span::styled(format!(" {}. {} ", i + 1, o.label), st), Span::styled(format!(" {}", o.description), Style::default().fg(th.muted))])); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled(format!("> {custom}_"), Style::default().fg(th.fg0)))); + lines.push(Line::from(Span::styled(t("question_keys"), Style::default().fg(th.muted)))); + let title = format!("{} {}/{}", t("question"), idx + 1, questions.len()); + let inner = boxed(f, &title, 90, lines.len() as u16 + 2, th.blue); + f.render_widget(Paragraph::new(lines), inner); + } + Dialog::Picker(p) => { + let vis = p.visible(); + let h = (vis.len() as u16).min(18) + 4; + let inner = boxed(f, &p.title, 90, h, th.accent); + let mut lines = vec![Line::from(Span::styled(if p.filter.is_empty() { format!("({})", t("filter")) } else { format!("> {}_", p.filter) }, Style::default().fg(th.muted)))]; + let first = p.sel.saturating_sub(15); + for (i, (key, label, detail)) in vis.iter().enumerate().skip(first).take(16) { + let current = match p.kind { + PickKind::Model => *key == self.session.model, + PickKind::Mode => *key == self.session.mode, + PickKind::Permission => *key == self.session.permission_mode, + _ => false, + }; + let st = if i == p.sel { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(if current { th.accent } else { th.fg1 }) }; + lines.push(Line::from(vec![Span::styled(format!(" {}{label} ", if current { "● " } else { " " }), st), Span::styled(format!(" {detail}"), Style::default().fg(th.muted))])); + } + f.render_widget(Paragraph::new(lines), inner); + } + Dialog::Setup(step) => { + let presets = crate::models::presets(); + let mut lines: Vec = Vec::new(); + match step { + SetupStep::Choose { sel, detected, note } => { + lines.push(Line::from(Span::styled(t("setup_intro"), Style::default().fg(th.fg2)))); + lines.push(Line::default()); + let cfg = crate::conf::Config::load().unwrap_or_default(); + let mut entries: Vec<(String, String)> = vec![(t("setup_server").into(), cfg.server.as_ref().map(|s| format!("✓ {}", s.url)).unwrap_or_default())]; + for p in &presets { + let mut d = String::new(); + if cfg.providers.contains_key(p.id) { + d.push_str("✓ "); + } + if detected.contains(&p.id) { + d.push_str(t("detected")); + } + entries.push((p.label.to_string(), d)); + } + entries.push((t("setup_done").into(), String::new())); + for (i, (l, d)) in entries.iter().enumerate() { + let st = if i == *sel { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(th.fg1) }; + lines.push(Line::from(vec![Span::styled(format!(" {l} "), st), Span::styled(format!(" {d}"), Style::default().fg(th.green))])); + } + if !note.is_empty() { + lines.push(Line::default()); + lines.push(Line::from(Span::styled(format!(" ✓ {note}"), Style::default().fg(th.green)))); + } + } + SetupStep::Server { fields, focus, error } => { + let labels = [t("server_url"), t("username"), t("password")]; + for i in 0..3 { + let val = if i == 2 { "•".repeat(fields[i].chars().count()) } else { fields[i].clone() }; + let st = if i == *focus { Style::default().fg(th.fg0).bg(th.bg2) } else { Style::default().fg(th.fg1) }; + lines.push(Line::from(vec![Span::styled(format!(" {:<14}", labels[i]), Style::default().fg(th.fg3)), Span::styled(format!(" {val}{} ", if i == *focus { "_" } else { "" }), st)])); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled(if error.is_empty() { t("enter_next").to_string() } else { error.clone() }, Style::default().fg(if error.is_empty() || error == "…" { th.muted } else { th.red })))); + } + SetupStep::Provider { preset, fields, focus, error } => { + let p = &presets[*preset]; + lines.push(Line::from(Span::styled(p.label, Style::default().fg(th.accent).add_modifier(Modifier::BOLD)))); + let labels = [t("provider_name"), t("base_url"), t("api_key")]; + for i in 0..3 { + let val = if i == 2 && !fields[i].starts_with("${") { "•".repeat(fields[i].chars().count()) } else { fields[i].clone() }; + let st = if i == *focus { Style::default().fg(th.fg0).bg(th.bg2) } else { Style::default().fg(th.fg1) }; + lines.push(Line::from(vec![Span::styled(format!(" {:<22}", labels[i]), Style::default().fg(th.fg3)), Span::styled(format!(" {val}{} ", if i == *focus { "_" } else { "" }), st)])); + } + if !p.key_hint.is_empty() { + lines.push(Line::from(Span::styled(format!(" {}", p.key_hint), Style::default().fg(th.muted)))); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled(if error.is_empty() { t("enter_next").to_string() } else { error.clone() }, Style::default().fg(if error.is_empty() || error == "…" { th.muted } else { th.red })))); + } + } + let inner = boxed(f, t("setup_title"), 80, lines.len() as u16 + 2, th.accent); + f.render_widget(Paragraph::new(lines), inner); + } + } + } +} + +/// Run the TUI until the user quits. +pub async fn run(mut app: App, mut ui: mpsc::UnboundedReceiver, initial: Option, setup: bool) -> Result<()> { + use crossterm::{event, execute, terminal}; + terminal::enable_raw_mode()?; + let mut out = std::io::stdout(); + execute!(out, terminal::EnterAlternateScreen, event::EnableMouseCapture, event::EnableBracketedPaste)?; + let enhanced = terminal::supports_keyboard_enhancement().unwrap_or(false); + if enhanced { + execute!(out, event::PushKeyboardEnhancementFlags(event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES))?; + } + // Restore the terminal on panic. + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = crossterm::terminal::disable_raw_mode(); + let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::event::DisableMouseCapture); + hook(info); + })); + let backend = ratatui::backend::CrosstermBackend::new(std::io::stdout()); + let mut term = ratatui::Terminal::new(backend)?; + let mut events = EventStream::new(); + let mut tick = tokio::time::interval(Duration::from_millis(120)); + if setup { + app.open_setup(); + } else if let Some(text) = initial { + app.send(text); + } + let result: Result<()> = async { + loop { + term.draw(|f| app.draw(f))?; + tokio::select! { + ev = events.next() => match ev { + Some(Ok(Event::Key(k))) => app.on_key(k), + Some(Ok(Event::Mouse(m))) => app.on_mouse(m), + Some(Ok(Event::Paste(s))) => { + if let Dialog::Permission { feedback: Some(fb), .. } = &mut app.dialog { fb.push_str(&s); } + else if matches!(app.dialog, Dialog::None) { app.editor.insert(&s); } + } + Some(Ok(_)) => {} + Some(Err(e)) => return Err(e.into()), + None => break, + }, + Some(ev) = ui.recv() => app.on_agent(ev), + _ = tick.tick() => { + app.tick += 1; + app.poll_pending(); + } + } + if app.quit { + break; + } + } + Ok(()) + } + .await; + if enhanced { + let _ = execute!(std::io::stdout(), event::PopKeyboardEnhancementFlags); + } + let _ = execute!(std::io::stdout(), event::DisableBracketedPaste, event::DisableMouseCapture, terminal::LeaveAlternateScreen); + terminal::disable_raw_mode()?; + if !app.session.messages.is_empty() { + println!("Session saved – continue with: codecraft --resume {}", app.session.id); + } + result +} + +impl App { + pub fn set_status(&mut self, s: String) { + self.status = s; + } +} diff --git a/cli/src/conf.rs b/cli/src/conf.rs new file mode 100644 index 0000000..9ad5abc --- /dev/null +++ b/cli/src/conf.rs @@ -0,0 +1,102 @@ +//! Local configuration (~/.config/codecraft/config.toml, readable only by the user). + +use anyhow::{Context, Result}; +use codecraft_core::config::ProviderConfig; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerLogin { + pub url: String, + pub token: String, + #[serde(default)] + pub username: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + /// Signed-in CodeCraft server (models, modes and rules come from there). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + /// Model used for new sessions ("provider/model", server models as "server:provider/model"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + /// Permission mode for new sessions (default: auto). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + /// UI language (en, de); default from the server or $LANG. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lang: Option, + /// Theme override, e.g. "nord" and "dark" (default: your server theme). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme_mode: Option, + /// Own model providers (standalone mode), same format as the server config. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub providers: BTreeMap, +} + +pub fn config_dir() -> PathBuf { + if let Some(x) = std::env::var_os("CODECRAFT_CONFIG_DIR") { + return PathBuf::from(x); + } + #[cfg(windows)] + if let Some(x) = std::env::var_os("APPDATA") { + return PathBuf::from(x).join("codecraft"); + } + let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".config")); + base.join("codecraft") +} + +pub fn data_dir() -> PathBuf { + if let Some(x) = std::env::var_os("CODECRAFT_DATA_DIR") { + return PathBuf::from(x); + } + #[cfg(windows)] + if let Some(x) = std::env::var_os("LOCALAPPDATA") { + return PathBuf::from(x).join("codecraft"); + } + let base = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".local").join("share")); + base.join("codecraft") +} + +pub fn home() -> PathBuf { + std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")) +} + +fn path() -> PathBuf { + config_dir().join("config.toml") +} + +impl Config { + pub fn load() -> Result { + let p = path(); + match std::fs::read_to_string(&p) { + Ok(text) => toml::from_str(&text).with_context(|| format!("invalid {}", p.display())), + Err(_) => Ok(Self::default()), + } + } + + pub fn save(&self) -> Result<()> { + let p = path(); + std::fs::create_dir_all(p.parent().unwrap())?; + let text = format!( + "# CodeCraft terminal client – edit with `codecraft setup` or by hand.\n# API keys may be written as \"${{ENV_VAR}}\".\n\n{}", + toml::to_string_pretty(self)? + ); + std::fs::write(&p, text)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) + } + + /// Nothing set up yet: the setup wizard runs first. + pub fn is_empty(&self) -> bool { + self.server.is_none() && self.providers.is_empty() + } +} diff --git a/cli/src/editor.rs b/cli/src/editor.rs new file mode 100644 index 0000000..70ad644 --- /dev/null +++ b/cli/src/editor.rs @@ -0,0 +1,219 @@ +//! Multi-line input field with history. + +use unicode_width::UnicodeWidthStr; + +#[derive(Default)] +pub struct Editor { + pub lines: Vec, + /// Cursor: line index and char index. + pub row: usize, + pub col: usize, + history: Vec, + hist_pos: Option, +} + +fn byte_idx(s: &str, ch: usize) -> usize { + s.char_indices().nth(ch).map(|(i, _)| i).unwrap_or(s.len()) +} + +impl Editor { + pub fn new() -> Self { + let history = std::fs::read_to_string(crate::conf::data_dir().join("history.txt")) + .map(|t| t.lines().map(|l| l.replace("\\n", "\n")).collect()) + .unwrap_or_default(); + Self { lines: vec![String::new()], history, ..Default::default() } + } + + pub fn text(&self) -> String { + self.lines.join("\n") + } + + pub fn is_empty(&self) -> bool { + self.lines.iter().all(|l| l.is_empty()) + } + + pub fn set(&mut self, text: &str) { + self.lines = text.split('\n').map(String::from).collect(); + if self.lines.is_empty() { + self.lines.push(String::new()); + } + self.row = self.lines.len() - 1; + self.col = self.lines[self.row].chars().count(); + } + + pub fn clear(&mut self) { + self.lines = vec![String::new()]; + self.row = 0; + self.col = 0; + self.hist_pos = None; + } + + /// Take the text (and remember it in the history). + pub fn take(&mut self) -> String { + let t = self.text(); + if !t.trim().is_empty() && self.history.last() != Some(&t) { + self.history.push(t.clone()); + if self.history.len() > 500 { + self.history.remove(0); + } + let _ = std::fs::create_dir_all(crate::conf::data_dir()); + let _ = std::fs::write(crate::conf::data_dir().join("history.txt"), self.history.iter().map(|h| h.replace('\n', "\\n")).collect::>().join("\n")); + } + self.clear(); + t + } + + pub fn insert(&mut self, s: &str) { + for (i, part) in s.split('\n').enumerate() { + if i > 0 { + self.newline(); + } + let part = part.replace('\r', "").replace('\t', " "); + let line = &mut self.lines[self.row]; + let at = byte_idx(line, self.col); + line.insert_str(at, &part); + self.col += part.chars().count(); + } + } + + pub fn newline(&mut self) { + let line = &mut self.lines[self.row]; + let at = byte_idx(line, self.col); + let rest = line.split_off(at); + self.lines.insert(self.row + 1, rest); + self.row += 1; + self.col = 0; + } + + pub fn backspace(&mut self) { + if self.col > 0 { + let line = &mut self.lines[self.row]; + let a = byte_idx(line, self.col - 1); + let b = byte_idx(line, self.col); + line.replace_range(a..b, ""); + self.col -= 1; + } else if self.row > 0 { + let line = self.lines.remove(self.row); + self.row -= 1; + self.col = self.lines[self.row].chars().count(); + self.lines[self.row].push_str(&line); + } + } + + pub fn delete(&mut self) { + let len = self.lines[self.row].chars().count(); + if self.col < len { + let line = &mut self.lines[self.row]; + let a = byte_idx(line, self.col); + let b = byte_idx(line, self.col + 1); + line.replace_range(a..b, ""); + } else if self.row + 1 < self.lines.len() { + let next = self.lines.remove(self.row + 1); + self.lines[self.row].push_str(&next); + } + } + + pub fn delete_word(&mut self) { + let chars: Vec = self.lines[self.row].chars().collect(); + let mut i = self.col; + while i > 0 && chars[i - 1].is_whitespace() { + i -= 1; + } + while i > 0 && !chars[i - 1].is_whitespace() { + i -= 1; + } + let line = &mut self.lines[self.row]; + let a = byte_idx(line, i); + let b = byte_idx(line, self.col); + line.replace_range(a..b, ""); + self.col = i; + } + + pub fn left(&mut self) { + if self.col > 0 { + self.col -= 1; + } else if self.row > 0 { + self.row -= 1; + self.col = self.lines[self.row].chars().count(); + } + } + + pub fn right(&mut self) { + if self.col < self.lines[self.row].chars().count() { + self.col += 1; + } else if self.row + 1 < self.lines.len() { + self.row += 1; + self.col = 0; + } + } + + pub fn home(&mut self) { + self.col = 0; + } + + pub fn end(&mut self) { + self.col = self.lines[self.row].chars().count(); + } + + /// Up: previous line, or older history entry on the first line. + pub fn up(&mut self) { + if self.row > 0 { + self.row -= 1; + self.col = self.col.min(self.lines[self.row].chars().count()); + return; + } + if self.history.is_empty() { + return; + } + let pos = match self.hist_pos { + None if !self.is_empty() => return, + None => self.history.len() - 1, + Some(0) => 0, + Some(p) => p - 1, + }; + self.hist_pos = Some(pos); + let h = self.history[pos].clone(); + self.set(&h); + } + + pub fn down(&mut self) { + if self.row + 1 < self.lines.len() { + self.row += 1; + self.col = self.col.min(self.lines[self.row].chars().count()); + return; + } + if let Some(p) = self.hist_pos { + if p + 1 < self.history.len() { + self.hist_pos = Some(p + 1); + let h = self.history[p + 1].clone(); + self.set(&h); + } else { + self.clear(); + } + } + } + + /// The word the cursor is in (for @file and /command completion): (start char, word). + pub fn current_word(&self) -> (usize, String) { + let chars: Vec = self.lines[self.row].chars().collect(); + let mut i = self.col.min(chars.len()); + while i > 0 && !chars[i - 1].is_whitespace() { + i -= 1; + } + (i, chars[i..self.col.min(chars.len())].iter().collect()) + } + + pub fn replace_word(&mut self, start: usize, with: &str) { + let line = &mut self.lines[self.row]; + let a = byte_idx(line, start); + let b = byte_idx(line, self.col); + line.replace_range(a..b, with); + self.col = start + with.chars().count(); + } + + /// Cursor position in display columns (for the terminal cursor). + pub fn cursor_x(&self) -> usize { + let line = &self.lines[self.row]; + UnicodeWidthStr::width(&line[..byte_idx(line, self.col)]) + } +} diff --git a/cli/src/i18n.rs b/cli/src/i18n.rs new file mode 100644 index 0000000..0e2784b --- /dev/null +++ b/cli/src/i18n.rs @@ -0,0 +1,60 @@ +//! UI texts (English and German). + +use std::sync::atomic::{AtomicBool, Ordering}; + +static GERMAN: AtomicBool = AtomicBool::new(false); + +pub fn set_lang(lang: &str) { + GERMAN.store(lang.starts_with("de"), Ordering::Relaxed); +} + +pub fn system_lang() -> String { + std::env::var("LC_ALL").or_else(|_| std::env::var("LANG")).unwrap_or_default().chars().take(2).collect() +} + +/// (key, English, German) +const TEXTS: &[(&str, &str, &str)] = &[ + ("placeholder", "Message… (/ commands, @ files, Shift+Enter new line)", "Nachricht… (/ Befehle, @ Dateien, Shift+Enter neue Zeile)"), + ("busy_placeholder", "Message is queued and sent after this turn…", "Nachricht wird nach diesem Schritt gesendet…"), + ("permission", "Permission required", "Freigabe erforderlich"), + ("perm_keys", "[y] allow once [a] always in this session [n] reject [f] reject with feedback", "[y] einmal erlauben [a] immer in dieser Session [n] ablehnen [f] ablehnen mit Hinweis"), + ("feedback", "Tell the agent what to do instead (Enter to send):", "Was soll der Agent stattdessen tun? (Enter zum Senden):"), + ("question", "Question", "Frage"), + ("question_keys", "↑↓ choose · Enter confirm · type your own answer · Esc dismiss", "↑↓ wählen · Enter bestätigen · eigene Antwort tippen · Esc verwerfen"), + ("thinking", "Thinking", "Denkt nach"), + ("working", "Working", "Arbeitet"), + ("interrupt", "Esc to interrupt", "Esc unterbricht"), + ("mode", "mode", "Modus"), + ("context", "context", "Kontext"), + ("quit_again", "Press Ctrl+C again to quit", "Nochmal Ctrl+C zum Beenden"), + ("aborted", "interrupted", "unterbrochen"), + ("no_model", "No model available – run /setup", "Kein Modell verfügbar – /setup ausführen"), + ("help", "Commands", "Befehle"), + ("choose_model", "Choose a model", "Modell wählen"), + ("choose_mode", "Choose a mode", "Modus wählen"), + ("choose_session", "Resume a session", "Session fortsetzen"), + ("choose_theme", "Choose a theme", "Theme wählen"), + ("filter", "type to filter", "tippen zum Filtern"), + ("queued", "queued", "in der Warteschlange"), + ("welcome", "Welcome to CodeCraft", "Willkommen bei CodeCraft"), + ("setup_title", "Set up CodeCraft", "CodeCraft einrichten"), + ("setup_intro", "Where should the models come from? You can add more later with /setup.", "Woher sollen die Modelle kommen? Weitere kannst du später mit /setup hinzufügen."), + ("setup_server", "Sign in to a CodeCraft server", "Bei einem CodeCraft-Server anmelden"), + ("setup_provider", "Add a model provider (API key or local)", "Modell-Anbieter hinzufügen (API-Key oder lokal)"), + ("setup_done", "Done", "Fertig"), + ("server_url", "Server URL", "Server-Adresse"), + ("username", "Username", "Benutzer"), + ("password", "Password", "Passwort"), + ("api_key", "API key", "API-Key"), + ("base_url", "Endpoint URL", "Endpunkt-URL"), + ("provider_name", "Name for this provider", "Name für diesen Anbieter"), + ("detected", "running locally", "läuft lokal"), + ("saved", "Saved.", "Gespeichert."), + ("models_found", "models found", "Modelle gefunden"), + ("enter_next", "Enter = next · Esc = back", "Enter = weiter · Esc = zurück"), +]; + +pub fn t(key: &str) -> &'static str { + let de = GERMAN.load(Ordering::Relaxed); + TEXTS.iter().find(|(k, _, _)| *k == key).map(|(_, en, d)| if de { *d } else { *en }).unwrap_or("?") +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 8bc7571..bea0da8 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,674 +1,377 @@ -//! `codecraft` – command line client for CodeCraft. +//! `codecraft` – CodeCraft in the terminal. //! -//! Authenticates with a personal API token (Settings → Account → API tokens). +//! A local coding agent: tools and files stay on this machine; models come +//! from a CodeCraft server (sign in) and/or your own providers (standalone). -use anyhow::{Context, Result, anyhow, bail}; +mod agent; +mod app; +mod conf; +mod editor; +mod i18n; +mod models; +mod server; +mod session; +mod stats; +mod theme; +mod tools; + +use agent::{Agent, Env, Reply, UiEvent, builtin_modes, modes_from_server}; +use anyhow::{Result, bail}; +use app::Cmd; use clap::{Parser, Subcommand}; -use futures_util::{SinkExt, StreamExt}; -use serde_json::{Value, json}; +use codecraft_core::config::PermissionConfig; +use conf::Config; +use serde_json::Value; +use session::LocalSession; use std::io::{IsTerminal, Write}; -use std::path::PathBuf; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio_tungstenite::tungstenite::{Message as WsMessage, client::IntoClientRequest}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; #[derive(Parser)] -#[command(name = "codecraft", version, about = "Command line client for CodeCraft", propagate_version = true)] +#[command(name = "codecraft", version, about = "CodeCraft in the terminal – a local coding agent")] struct Cli { - /// Server URL (overrides the saved login) - #[arg(long, env = "CODECRAFT_URL", global = true)] - url: Option, - /// API token (overrides the saved login) - #[arg(long, env = "CODECRAFT_TOKEN", global = true, hide_env_values = true)] - token: Option, + /// Start with this prompt + prompt: Option, + /// Continue the last session in this folder + #[arg(short, long)] + r#continue: bool, + /// Resume a session by id + #[arg(long)] + resume: Option, + /// Model ("provider/model" or "server:provider/model") + #[arg(short, long)] + model: Option, + /// Mode: build, plan, craft … + #[arg(long)] + mode: Option, #[command(subcommand)] - cmd: Cmd, + cmd: Option, } #[derive(Subcommand)] -enum Cmd { - /// Save server URL and API token (Settings → Account → API tokens) - Login { url: String, token: String }, - /// Remove the saved login +enum Sub { + /// Sign in to a CodeCraft server (creates an API token for this machine) + Login { url: Option, username: Option }, + /// Forget the server login Logout, - /// Server, user, running sessions and costs this month - Status, - /// List sessions - Sessions { - /// Show at most N sessions - #[arg(short = 'n', long, default_value_t = 20)] - limit: usize, - }, - /// Print a session's conversation - Show { session: String }, - /// Export a session as Markdown or JSON - Export { - session: String, - #[arg(long, default_value = "md")] - format: String, - #[arg(short, long)] - output: Option, - }, - /// Start a new session with a prompt and follow it + /// Guided setup: server login and own model providers + Setup, + /// Run one prompt without the full-screen UI (for scripts) Run { prompt: String, - /// Working directory on the server (default: your folder) - #[arg(long)] - cwd: Option, - /// Mode: build, plan, craft, … - #[arg(long, default_value = "build")] - agent: String, - /// Model "provider/model" - #[arg(long)] - model: Option, - /// Permission mode: default, auto, accept_edits, bypass - #[arg(long)] - mode: Option, - /// Reasoning effort: low, medium, high, max - #[arg(long)] - effort: Option, - /// Don't wait – print the session id and exit - #[arg(long)] - detach: bool, - }, - /// Send a prompt to an existing session and follow it - Send { - session: String, - prompt: String, - #[arg(long)] - effort: Option, - }, - /// Interactive chat (new session, or continue one) - Chat { - session: Option, - #[arg(long)] - cwd: Option, - #[arg(long, default_value = "build")] - agent: String, - #[arg(long)] - model: Option, - }, - /// Follow a running session (answer permissions and questions) - Attach { session: String }, - /// Stop a running session - Stop { session: String }, - /// Automations - Jobs { - #[command(subcommand)] - cmd: Option, - }, - /// Files in your folders - Files { - #[command(subcommand)] - cmd: FileCmd, - }, - /// Costs and tokens of the last days - Costs { - #[arg(long, default_value_t = 30)] - days: u32, - }, -} - -#[derive(Subcommand)] -enum JobCmd { - /// List automations (default) - List, - /// Start an automation now - Run { id: String }, - /// Enable an automation - On { id: String }, - /// Disable an automation - Off { id: String }, -} - -#[derive(Subcommand)] -enum FileCmd { - /// List a folder - Ls { path: Option }, - /// Download a file - Get { - path: String, #[arg(short, long)] - output: Option, + model: Option, + #[arg(long, default_value = "build")] + mode: String, + /// Permission mode: default, auto, accept_edits, bypass + #[arg(long, default_value = "auto")] + permissions: String, }, - /// Upload a file (chunked, large files supported) - Put { local: PathBuf, remote: String }, + /// Update codecraft from your server + Update, + /// List local sessions + Sessions, } -// --- config ------------------------------------------------------------------------ - -fn config_path() -> PathBuf { - let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".config")); - base.join("codecraft").join("cli.json") -} - -fn load_login(cli: &Cli) -> Result<(String, String)> { - let saved: Value = std::fs::read_to_string(config_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); - let url = cli.url.clone().or_else(|| saved["url"].as_str().map(String::from)); - let token = cli.token.clone().or_else(|| saved["token"].as_str().map(String::from)); - match (url, token) { - (Some(u), Some(t)) => Ok((u.trim_end_matches('/').to_string(), t)), - _ => bail!("not logged in – run `codecraft login ` (create a token under Settings → Account → API tokens)"), - } -} - -// --- output ------------------------------------------------------------------------ - -struct Style { - on: bool, -} - -impl Style { - fn paint(&self, code: &str, s: &str) -> String { - if self.on { format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() } - } - fn dim(&self, s: &str) -> String { - self.paint("2", s) - } - fn bold(&self, s: &str) -> String { - self.paint("1", s) - } - fn accent(&self, s: &str) -> String { - self.paint("33", s) - } - fn red(&self, s: &str) -> String { - self.paint("31", s) - } - fn green(&self, s: &str) -> String { - self.paint("32", s) - } -} - -fn short(id: &str) -> &str { - &id[id.len().saturating_sub(8)..] -} - -fn fmt_time(ms: i64) -> String { - let secs = (now_ms() - ms) / 1000; - match secs { - s if s < 60 => "just now".into(), - s if s < 3600 => format!("{} min ago", s / 60), - s if s < 86_400 => format!("{} h ago", s / 3600), - s => format!("{} d ago", s / 86_400), - } -} - -fn now_ms() -> i64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) -} - -// --- API --------------------------------------------------------------------------- - -struct Api { - base: String, - token: String, - http: reqwest::Client, -} - -impl Api { - fn new(base: String, token: String) -> Self { - let http = reqwest::Client::builder().user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION"))).build().expect("http client"); - Self { base, token, http } - } - - async fn send(&self, req: reqwest::RequestBuilder) -> Result { - let resp = req.bearer_auth(&self.token).send().await.context("cannot reach the server")?; - if resp.status().is_success() { - return Ok(resp); - } - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - let msg = serde_json::from_str::(&body).ok().and_then(|v| v["error"].as_str().map(String::from)).unwrap_or(body); - if status.as_u16() == 401 { - bail!("unauthorized – the token is invalid or expired (codecraft login …)"); - } - bail!("{} {}", status.as_u16(), msg.trim()) - } - - async fn get(&self, path: &str) -> Result { - Ok(self.send(self.http.get(format!("{}/api{path}", self.base))).await?.json().await?) - } - - async fn post(&self, path: &str, body: Value) -> Result { - Ok(self.send(self.http.post(format!("{}/api{path}", self.base)).json(&body)).await?.json().await?) - } - - async fn patch(&self, path: &str, body: Value) -> Result { - Ok(self.send(self.http.patch(format!("{}/api{path}", self.base)).json(&body)).await?.json().await?) - } - - /// Resolve a full or shortened (suffix) session id. - async fn session_id(&self, id: &str) -> Result { - if id.len() >= 24 { - return Ok(id.to_string()); - } - let list = self.get("/sessions").await?; - let hits: Vec = list.as_array().into_iter().flatten().filter_map(|s| s["id"].as_str()).filter(|s| s.ends_with(id)).map(String::from).collect(); - match hits.len() { - 1 => Ok(hits[0].clone()), - 0 => bail!("no session matches '{id}'"), - _ => bail!("'{id}' is ambiguous – use more characters"), +/// Configuration, models and modes (server and/or local). +pub async fn build_env() -> Result { + let cfg = Config::load()?; + let server = cfg.server.as_ref().map(server::Server::new); + let mut server_cfg = None; + let mut offline = None; + if let Some(s) = &server { + match tokio::time::timeout(std::time::Duration::from_secs(10), s.get("/cli/config")).await { + Ok(Ok(v)) => server_cfg = Some(v), + Ok(Err(e)) => offline = Some(format!("{e:#}")), + Err(_) => offline = Some("server timeout".into()), } } + let catalog = models::Catalog::load(&cfg, server_cfg.as_ref()).await; + let sc = server_cfg.clone().unwrap_or(Value::Null); + let modes = if server_cfg.is_some() { modes_from_server(&sc) } else { builtin_modes() }; + let permissions: PermissionConfig = serde_json::from_value(sc["permissions"].clone()).unwrap_or_default(); + let remote_tools = sc["mcp_tools"] + .as_array() + .into_iter() + .flatten() + .map(|t| tools::ToolSpec { + name: t["name"].as_str().unwrap_or("").into(), + description: t["description"].as_str().unwrap_or("").into(), + schema: t["input_schema"].clone(), + read_only: false, + is_edit: false, + remote: true, + }) + .collect(); + let server_skills = sc["skills"].as_array().into_iter().flatten().map(|s| (s["name"].as_str().unwrap_or("").to_string(), s["description"].as_str().unwrap_or("").to_string())).collect(); + // Language and theme: local override, else the server's, else the system's. + let prefs = &sc["prefs"]; + let lang = cfg.lang.clone().or_else(|| prefs["lang"].as_str().map(String::from)).unwrap_or_else(i18n::system_lang); + i18n::set_lang(&lang); + let base = cfg.theme.clone().or_else(|| prefs["theme"].as_str().map(String::from)).unwrap_or_else(|| "gruvbox".into()); + let mode = cfg.theme_mode.clone().or_else(|| prefs["theme_mode"].as_str().map(String::from)).unwrap_or_else(|| "auto".into()); + theme::set(theme::Theme::load(&base, &mode)); + let env = Env { + base_prompt: sc["base_prompt"].as_str().unwrap_or(codecraft_core::prompts::BASE_PROMPT).to_string(), + auto_prompt: sc["auto_mode_prompt"].as_str().unwrap_or(codecraft_core::prompts::AUTO_MODE_PROMPT).to_string(), + memory: sc["memory"].as_str().unwrap_or("").to_string(), + cfg, + server, + server_cfg, + catalog, + modes, + permissions, + remote_tools, + server_skills, + }; + if let Some(e) = offline { + eprintln!("warning: CodeCraft server not reachable ({e}) – using local providers only"); + } + Ok(env) } -// --- following a session ----------------------------------------------------------- - -/// Read one line from stdin (async, so events keep flowing). -async fn read_line(prompt: &str) -> Result> { - print!("{prompt}"); - std::io::stdout().flush()?; - let mut line = String::new(); - let n = BufReader::new(tokio::io::stdin()).read_line(&mut line).await?; - Ok(if n == 0 { None } else { Some(line.trim_end_matches(['\r', '\n']).to_string()) }) -} - -async fn answer_permission(api: &Api, st: &Style, ev: &Value) -> Result<()> { - println!("\n{} {} {}", st.accent("? Permission"), st.bold(ev["tool"].as_str().unwrap_or("")), ev["title"].as_str().unwrap_or("")); - if let Some(cmd) = ev["input"]["command"].as_str() { - println!(" {}", st.dim(&format!("$ {cmd}"))); +fn prompt_line(label: &str, secret: bool) -> Result { + eprint!("{label}: "); + std::io::stderr().flush()?; + if !secret || !std::io::stdin().is_terminal() { + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + return Ok(s.trim().to_string()); } - if let Some(diff) = ev["diff"].as_str() { - for l in diff.lines().take(60) { - let line = if l.starts_with('+') && !l.starts_with("+++") { - st.green(l) - } else if l.starts_with('-') && !l.starts_with("---") { - st.red(l) - } else { - st.dim(l) - }; - println!(" {line}"); - } - } - loop { - let Some(ans) = read_line(" [y]es once / [a]lways / [n]o (n: ) > ").await? else { return Ok(()) }; - let ans = ans.trim(); - let (reply, message) = match ans.chars().next().map(|c| c.to_ascii_lowercase()) { - Some('y') | Some('j') => ("once", None), - Some('a') => ("always", None), - Some('n') => ("reject", ans.split_once(':').map(|(_, m)| m.trim().to_string()).filter(|m| !m.is_empty())), - _ => continue, - }; - api.post(&format!("/permissions/{}", ev["id"].as_str().unwrap_or("")), json!({"reply": reply, "message": message})).await?; - return Ok(()); - } -} - -async fn answer_question(api: &Api, st: &Style, ev: &Value) -> Result<()> { - let mut answers = serde_json::Map::new(); - for q in ev["questions"].as_array().into_iter().flatten() { - let text = q["question"].as_str().unwrap_or(""); - println!("\n{} {}", st.accent("? Question"), st.bold(text)); - let options: Vec<&str> = q["options"].as_array().into_iter().flatten().filter_map(|o| o["label"].as_str()).collect(); - for (i, o) in options.iter().enumerate() { - println!(" {}. {o}", i + 1); - } - let Some(ans) = read_line(" answer (number or text) > ").await? else { return Ok(()) }; - let value = ans.trim().parse::().ok().and_then(|n| options.get(n.wrapping_sub(1)).map(|s| s.to_string())).unwrap_or_else(|| ans.trim().to_string()); - answers.insert(text.to_string(), Value::String(value)); - } - api.post(&format!("/questions/{}", ev["id"].as_str().unwrap_or("")), json!({"answers": answers})).await?; - Ok(()) -} - -/// Stream a session's events until it is idle. `wait_start`: the turn was just -/// submitted, so an initial "idle" state is not the end yet. -async fn follow(api: &Api, st: &Style, session: &str, wait_start: bool) -> Result<()> { - let ws_url = format!("{}/api/ws", api.base.replacen("http", "ws", 1)); - let mut req = ws_url.into_client_request()?; - req.headers_mut().insert("Authorization", format!("Bearer {}", api.token).parse()?); - let (mut ws, _) = tokio_tungstenite::connect_async(req).await.context("cannot open the event stream")?; - - // Requests that were already pending before we connected. - for ev in api.get("/pending").await?.as_array().into_iter().flatten() { - if ev["root_session_id"] == session || ev["session_id"] == session { - handle_interaction(api, st, ev).await?; - } - } - let mut started = !wait_start || api.get(&format!("/sessions/{session}")).await?["busy"] == true; - let mut in_text = false; - let mut in_thinking = false; - let idle_check = tokio::time::sleep(std::time::Duration::from_secs(5)); - tokio::pin!(idle_check); - loop { - tokio::select! { - msg = ws.next() => { - let Some(msg) = msg else { break }; - let text = match msg? { - WsMessage::Text(t) => t.to_string(), - WsMessage::Ping(p) => { ws.send(WsMessage::Pong(p)).await?; continue } - WsMessage::Close(_) => break, - _ => continue, - }; - let ev: Value = serde_json::from_str(&text).unwrap_or(Value::Null); - let sid = ev["session_id"].as_str().or(ev["session"]["id"].as_str()).unwrap_or(""); - let root = ev["root_session_id"].as_str().unwrap_or(sid); - let mine = sid == session; - match ev["type"].as_str().unwrap_or("") { - "text_delta" if mine => { - if in_thinking { println!(); in_thinking = false; } - print!("{}", ev["text"].as_str().unwrap_or("")); - std::io::stdout().flush()?; - in_text = true; - } - "reasoning_delta" if mine => { - if !in_thinking { print!("{}", st.dim("\n[thinking] ")); in_thinking = true; } - print!("{}", st.dim(ev["text"].as_str().unwrap_or(""))); - std::io::stdout().flush()?; - } - "tool_started" if mine => { - if in_text || in_thinking { println!(); in_text = false; in_thinking = false; } - let input = &ev["input"]; - let detail = input["description"].as_str().or(input["command"].as_str()).or(input["path"].as_str()).or(input["pattern"].as_str()).or(input["query"].as_str()).unwrap_or(""); - println!("{} {} {}", st.accent("▸"), st.bold(ev["name"].as_str().unwrap_or("")), st.dim(detail)); - } - "permission_request" | "question_request" if root == session || mine => { - if in_text { println!(); in_text = false; } - handle_interaction(api, st, &ev).await?; - } - "error" if mine => { - println!("\n{} {}", st.red("error:"), ev["message"].as_str().unwrap_or("")); - } - "waiting" if mine => { - if let Some(r) = ev["reason"].as_str() { println!("{}", st.dim(&format!("… waiting ({r})"))); } - } - "session_status" if mine => { - if ev["busy"] == true { started = true; } - else if started { if in_text { println!(); } break; } - } - _ => {} + use crossterm::event::{Event, KeyCode, KeyEventKind, read}; + crossterm::terminal::enable_raw_mode()?; + let mut s = String::new(); + let res = loop { + match read() { + Ok(Event::Key(k)) if k.kind != KeyEventKind::Release => match k.code { + KeyCode::Enter => break Ok(()), + KeyCode::Backspace => { + s.pop(); } - } - _ = &mut idle_check, if !started => { - // The turn may have finished before we connected. - if api.get(&format!("/sessions/{session}")).await?["busy"] != true { break; } - started = true; - } - _ = tokio::signal::ctrl_c() => { - println!("\n{}", st.dim("detached – the session keeps running (codecraft stop to stop it)")); - break; - } + KeyCode::Esc => break Err(anyhow::anyhow!("cancelled")), + KeyCode::Char('c') if k.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => break Err(anyhow::anyhow!("cancelled")), + KeyCode::Char(c) => s.push(c), + _ => {} + }, + Ok(_) => {} + Err(e) => break Err(e.into()), } + }; + crossterm::terminal::disable_raw_mode()?; + eprintln!(); + res.map(|_| s) +} + +fn version_newer(server: &str, ours: &str) -> bool { + let parse = |v: &str| v.split('.').map(|p| p.parse::().unwrap_or(0)).collect::>(); + parse(server) > parse(ours) +} + +fn target() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} + +async fn self_update() -> Result<()> { + let cfg = Config::load()?; + let Some(login) = &cfg.server else { bail!("not signed in – updates come from your CodeCraft server (codecraft login)") }; + let http = server::http(); + let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?; + let latest = info["version"].as_str().unwrap_or("0"); + if !version_newer(latest, env!("CARGO_PKG_VERSION")) { + println!("codecraft {} is up to date.", env!("CARGO_PKG_VERSION")); + return Ok(()); } + let url = format!("{}/cli/download/{}", login.url, target()); + println!("Downloading codecraft {latest} for {} …", target()); + let resp = http.get(&url).send().await?; + if !resp.status().is_success() { + bail!("no build for {} on the server ({})", target(), resp.status()); + } + let bytes = resp.bytes().await?; + let exe = std::env::current_exe()?; + let tmp = exe.with_extension("new"); + std::fs::write(&tmp, &bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?; + } + std::fs::rename(&tmp, &exe)?; + println!("Updated to {latest}."); Ok(()) } -async fn handle_interaction(api: &Api, st: &Style, ev: &Value) -> Result<()> { - if !std::io::stdin().is_terminal() { - println!("{}", st.accent("? waiting for an answer in the web UI (stdin is not a terminal)")); - return Ok(()); - } - match ev["type"].as_str() { - Some("permission_request") => answer_permission(api, st, ev).await, - Some("question_request") => answer_question(api, st, ev).await, - _ => Ok(()), - } +/// Process commands for the agent (one turn at a time). +fn spawn_agent(env: Arc, session: LocalSession, ui: mpsc::UnboundedSender) -> mpsc::UnboundedSender { + let (tx, mut rx) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + let mut agent = Agent::new(env, session, ui.clone()); + while let Some(cmd) = rx.recv().await { + match cmd { + Cmd::Prompt { text, images, effort, cancel } => agent.prompt(text, images, effort, cancel).await, + Cmd::SetModel(m) => { + agent.session.model = m; + let _ = agent.session.save(); + } + Cmd::SetMode(m) => { + agent.session.mode = m; + let _ = agent.session.save(); + } + Cmd::SetPermission(p) => { + agent.session.permission_mode = p; + let _ = agent.session.save(); + } + Cmd::Load(s) => agent = Agent::new(agent.env.clone(), *s, ui.clone()), + Cmd::New => { + let s = LocalSession::new(&agent.session.cwd, &agent.session.model, &agent.session.mode, &agent.session.permission_mode); + agent = Agent::new(agent.env.clone(), s, ui.clone()); + } + Cmd::Compact(cancel) => { + let _ = ui.send(UiEvent::Busy(true)); + let model = agent.env.catalog.find(&agent.session.model).cloned(); + match model { + Some(m) => { + if let Err(e) = agent.compact(&m, &cancel).await { + let _ = ui.send(UiEvent::Error(format!("{e:#}"))); + } + } + None => { + let _ = ui.send(UiEvent::Error(i18n::t("no_model").into())); + } + } + let _ = ui.send(UiEvent::Busy(false)); + } + Cmd::SetEnv(e) => agent.env = e, + } + } + }); + tx } -async fn create_session(api: &Api, cwd: Option, agent: &str, model: Option, mode: Option) -> Result { - let s = api.post("/sessions", json!({"cwd": cwd, "agent": agent, "model": model, "permission_mode": mode})).await?; - s["id"].as_str().map(String::from).ok_or_else(|| anyhow!("unexpected answer from the server")) +fn default_model(env: &Env, cfg: &Config, wanted: Option) -> String { + wanted + .or_else(|| cfg.default_model.clone()) + .filter(|m| env.catalog.find(m).is_some()) + .or_else(|| env.server_cfg.as_ref().and_then(|s| s["default_model"].as_str()).map(|m| format!("{}{m}", models::SERVER_PREFIX)).filter(|m| env.catalog.find(m).is_some())) + .or_else(|| env.catalog.models.first().map(|m| m.spec.clone())) + .unwrap_or_default() } -// --- commands ------------------------------------------------------------------------ +/// One prompt without the UI: answer on stdout, tools on stderr. +async fn run_once(prompt: String, model: Option, mode: String, permissions: String) -> Result<()> { + let env = Arc::new(build_env().await?); + let cwd = std::env::current_dir()?.display().to_string(); + let model = default_model(&env, &env.cfg.clone(), model); + if model.is_empty() { + bail!("no model configured – run `codecraft setup`"); + } + let session = LocalSession::new(&cwd, &model, &mode, &permissions); + let (ui_tx, mut ui_rx) = mpsc::unbounded_channel(); + let mut agent = Agent::new(env, session, ui_tx); + let cancel = CancellationToken::new(); + let task = tokio::spawn(async move { agent.prompt(prompt, vec![], None, cancel).await }); + let interactive = std::io::stdin().is_terminal(); + while let Some(ev) = ui_rx.recv().await { + match ev { + UiEvent::Text(t) => { + print!("{t}"); + std::io::stdout().flush()?; + } + UiEvent::ToolStart { name, input, .. } => eprintln!("\n> {name} {}", input["description"].as_str().or(input["command"].as_str()).or(input["path"].as_str()).unwrap_or("")), + UiEvent::Error(e) => eprintln!("error: {e}"), + UiEvent::Permission { tool, title, reply, .. } => { + let answer = if interactive { prompt_line(&format!("allow {tool} {title}? [y/a/N]"), false)? } else { String::new() }; + let _ = reply.send(match answer.as_str() { + "y" | "j" => Reply::Once, + "a" => Reply::Always, + _ => Reply::Reject(Some(if interactive { "rejected".into() } else { "not allowed in non-interactive mode – use --permissions bypass or accept_edits".into() })), + }); + } + UiEvent::Question { reply, .. } => { + let _ = reply.send(None); + } + UiEvent::Busy(false) => break, + _ => {} + } + } + let _ = task.await; + println!(); + Ok(()) +} #[tokio::main] -async fn main() { +async fn main() -> Result<()> { let cli = Cli::parse(); - let st = Style { on: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() }; - if let Err(e) = run(cli, &st).await { - eprintln!("{} {e:#}", st.red("error:")); - std::process::exit(1); - } -} - -async fn run(cli: Cli, st: &Style) -> Result<()> { - if let Cmd::Login { url, token } = &cli.cmd { - let api = Api::new(url.trim_end_matches('/').to_string(), token.clone()); - let info = api.get("/info").await?; - let user = info["user"]["username"].as_str().ok_or_else(|| anyhow!("the token was not accepted"))?; - let path = config_path(); - std::fs::create_dir_all(path.parent().unwrap())?; - std::fs::write(&path, serde_json::to_string_pretty(&json!({"url": api.base, "token": token}))?)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - } - println!("Logged in to {} as {} (CodeCraft {}). Saved to {}", api.base, st.bold(user), info["version"].as_str().unwrap_or("?"), path.display()); - return Ok(()); - } - if let Cmd::Logout = cli.cmd { - let _ = std::fs::remove_file(config_path()); - println!("Logged out."); - return Ok(()); - } - let (url, token) = load_login(&cli)?; - let api = Api::new(url, token); - match cli.cmd { - Cmd::Login { .. } | Cmd::Logout => unreachable!(), - Cmd::Status => { - let info = api.get("/info").await?; - let sessions = api.get("/sessions").await?; - let busy: Vec<&Value> = sessions.as_array().into_iter().flatten().filter(|s| s["busy"] == true).collect(); - let stats = api.get(&format!("/stats?from={}", month_start_ms())).await.unwrap_or(Value::Null); - println!("{} {} · {}", st.bold("CodeCraft"), info["version"].as_str().unwrap_or("?"), api.base); - println!("User: {}{}", info["user"]["username"].as_str().unwrap_or("?"), if info["user"]["role"] == "admin" { " (admin)" } else { "" }); - println!("Running: {} session(s)", busy.len()); - for s in busy { - println!(" {} {}", st.dim(short(s["id"].as_str().unwrap_or(""))), s["title"].as_str().unwrap_or("")); - } - if let Some(cost) = stats["total"]["cost"].as_f64() { - println!("30 days: {cost:.2} {} · {} requests", stats["currency"].as_str().unwrap_or(""), stats["total"]["requests"]); - } - } - Cmd::Sessions { limit } => { - let list = api.get("/sessions").await?; - for s in list.as_array().into_iter().flatten().take(limit) { - let busy = if s["busy"] == true { st.accent(" ● running") } else { String::new() }; - println!( - "{} {:<40} {}{}", - st.dim(short(s["id"].as_str().unwrap_or(""))), - truncate(s["title"].as_str().filter(|t| !t.is_empty()).unwrap_or("(untitled)"), 40), - st.dim(&format!("{} · {} · {}", s["agent"].as_str().unwrap_or(""), s["cwd"].as_str().unwrap_or(""), fmt_time(s["updated_at"].as_i64().unwrap_or(0)))), - busy - ); - } - } - Cmd::Show { session } => { - let id = api.session_id(&session).await?; - let s = api.get(&format!("/sessions/{id}")).await?; - println!("{} {}", st.bold(s["session"]["title"].as_str().unwrap_or("")), st.dim(s["session"]["cwd"].as_str().unwrap_or(""))); - for m in s["messages"].as_array().into_iter().flatten() { - for p in m["parts"].as_array().into_iter().flatten() { - match (m["role"].as_str(), p["type"].as_str()) { - (Some("user"), Some("text")) => println!("\n{} {}", st.accent("you ›"), p["text"].as_str().unwrap_or("")), - (_, Some("text")) => println!("\n{}", p["text"].as_str().unwrap_or("")), - (_, Some("tool_call")) => println!("{} {}", st.accent("▸"), st.bold(p["name"].as_str().unwrap_or(""))), - _ => {} - } - } - } - } - Cmd::Export { session, format, output } => { - let id = api.session_id(&session).await?; - let resp = api.send(api.http.get(format!("{}/api/sessions/{id}/export?format={format}", api.base))).await?; - let bytes = resp.bytes().await?; - match output { - Some(p) => { - std::fs::write(&p, &bytes)?; - println!("Saved {}", p.display()); - } - None => std::io::stdout().write_all(&bytes)?, - } - } - Cmd::Run { prompt, cwd, agent, model, mode, effort, detach } => { - let id = create_session(&api, cwd, &agent, model, mode).await?; - api.post(&format!("/sessions/{id}/prompt"), json!({"text": prompt, "effort": effort})).await?; - if detach { - println!("{id}"); - return Ok(()); - } - println!("{}", st.dim(&format!("session {} · {}/#/s/{id}", short(&id), api.base))); - follow(&api, st, &id, true).await?; - } - Cmd::Send { session, prompt, effort } => { - let id = api.session_id(&session).await?; - api.post(&format!("/sessions/{id}/prompt"), json!({"text": prompt, "effort": effort})).await?; - follow(&api, st, &id, true).await?; - } - Cmd::Chat { session, cwd, agent, model } => { - let id = match session { - Some(s) => api.session_id(&s).await?, - None => create_session(&api, cwd, &agent, model, None).await?, + Some(Sub::Login { url, username }) => { + let url = match url { + Some(u) => u, + None => prompt_line("CodeCraft server URL", false)?, }; - println!("{}", st.dim(&format!("chat in session {} – empty line or Ctrl+D to quit", short(&id)))); - loop { - let Some(line) = read_line(&format!("\n{} ", st.accent("you ›"))).await? else { break }; - if line.trim().is_empty() { - break; - } - api.post(&format!("/sessions/{id}/prompt"), json!({"text": line})).await?; - follow(&api, st, &id, true).await?; - } + let user = match username { + Some(u) => u, + None => prompt_line("Username", false)?, + }; + let pass = prompt_line("Password", true)?; + let login = server::login(&url, &user, &pass).await?; + let mut cfg = Config::load()?; + println!("Signed in to {} as {}.", login.url, login.username); + cfg.server = Some(login); + cfg.save()?; + return Ok(()); } - Cmd::Attach { session } => { - let id = api.session_id(&session).await?; - follow(&api, st, &id, false).await?; + Some(Sub::Logout) => { + let mut cfg = Config::load()?; + cfg.server = None; + cfg.save()?; + println!("Signed out (revoke the token under Settings → Account → API tokens if needed)."); + return Ok(()); } - Cmd::Stop { session } => { - let id = api.session_id(&session).await?; - api.post(&format!("/sessions/{id}/abort"), json!({})).await?; - println!("Stopped {}.", short(&id)); + Some(Sub::Update) => return self_update().await, + Some(Sub::Run { prompt, model, mode, permissions }) => return run_once(prompt, model, mode, permissions).await, + Some(Sub::Sessions) => { + for s in session::list(None).into_iter().take(50) { + let when = chrono::DateTime::from_timestamp_millis(s.updated_at).map(|d| d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); + println!("{} {when} {:<40} {}", s.id, s.title.chars().take(40).collect::(), s.cwd); + } + return Ok(()); } - Cmd::Jobs { cmd } => match cmd.unwrap_or(JobCmd::List) { - JobCmd::List => { - for j in api.get("/jobs").await?.as_array().into_iter().flatten() { - let on = if j["enabled"] == true { st.green("on ") } else { st.dim("off") }; - println!( - "{} {} {:<30} {} {}", - st.dim(short(j["id"].as_str().unwrap_or(""))), - on, - truncate(j["spec"]["name"].as_str().unwrap_or("?"), 30), - j["spec"]["schedule"].as_str().unwrap_or("?"), - st.dim(&format!("last: {}", j["last_status"].as_str().unwrap_or("-"))) - ); - } - } - JobCmd::Run { id } => { - let id = job_id(&api, &id).await?; - let r = api.post(&format!("/jobs/{id}/run"), json!({})).await?; - let sid = r["session_id"].as_str().unwrap_or(""); - println!("{}", st.dim(&format!("started session {}", short(sid)))); - follow(&api, st, sid, true).await?; - } - JobCmd::On { id } => { - let id = job_id(&api, &id).await?; - api.patch(&format!("/jobs/{id}"), json!({"enabled": true})).await?; - println!("Enabled."); - } - JobCmd::Off { id } => { - let id = job_id(&api, &id).await?; - api.patch(&format!("/jobs/{id}"), json!({"enabled": false})).await?; - println!("Disabled."); - } - }, - Cmd::Files { cmd } => match cmd { - FileCmd::Ls { path } => { - let q = path.map(|p| format!("?path={}", urlencode(&p))).unwrap_or_default(); - let l = api.get(&format!("/files/list{q}")).await?; - println!("{}", st.dim(l["path"].as_str().unwrap_or(""))); - for e in l["entries"].as_array().into_iter().flatten() { - let name = e["name"].as_str().unwrap_or(""); - if e["is_dir"] == true { - println!(" {}/", st.bold(name)); - } else { - println!(" {name:<40} {}", st.dim(&human(e["size"].as_u64().unwrap_or(0)))); - } - } - } - FileCmd::Get { path, output } => { - let resp = api.send(api.http.get(format!("{}/api/files/raw?path={}&download=true", api.base, urlencode(&path)))).await?; - let bytes = resp.bytes().await?; - let out = output.unwrap_or_else(|| PathBuf::from(path.rsplit('/').next().unwrap_or("download"))); - std::fs::write(&out, &bytes)?; - println!("Saved {} ({})", out.display(), human(bytes.len() as u64)); - } - FileCmd::Put { local, remote } => { - let data = tokio::fs::read(&local).await.with_context(|| format!("cannot read {}", local.display()))?; - let remote = if remote.ends_with('/') { format!("{remote}{}", local.file_name().and_then(|n| n.to_str()).unwrap_or("upload")) } else { remote }; - const CHUNK: usize = 8 * 1024 * 1024; - let mut offset = 0usize; - loop { - let end = (offset + CHUNK).min(data.len()); - let url = format!("{}/api/files/upload?path={}&offset={offset}", api.base, urlencode(&remote)); - api.send(api.http.put(url).body(data[offset..end].to_vec())).await?; - offset = end; - if std::io::stderr().is_terminal() { - eprint!("\r{} / {}", human(offset as u64), human(data.len() as u64)); - } - if offset >= data.len() { - break; - } - } - eprintln!(); - println!("Uploaded to {remote}"); - } - }, - Cmd::Costs { days } => { - let from = now_ms() - days as i64 * 86_400_000; - let s = api.get(&format!("/stats?from={from}")).await?; - let cur = s["currency"].as_str().unwrap_or(""); - let t = &s["total"]; - println!("{} (last {days} days)", st.bold("Costs")); - println!(" total {:.2} {cur}", t["cost"].as_f64().unwrap_or(0.0)); - println!(" requests {}", t["requests"]); - println!(" tokens {} in · {} out", t["input_tokens"], t["output_tokens"]); - if let Some(models) = s["by_model"].as_array() { - println!(" {}", st.dim("by model:")); - for m in models { - println!(" {:<40} {:>8.2} {cur}", truncate(m["key"].as_str().or(m["model"].as_str()).unwrap_or("?"), 40), m["cost"].as_f64().unwrap_or(0.0)); - } - } + Some(Sub::Setup) | None => {} + } + let setup = matches!(cli.cmd, Some(Sub::Setup)) || Config::load()?.is_empty(); + let env = Arc::new(build_env().await?); + let cwd = std::env::current_dir()?.display().to_string(); + let cfg = env.cfg.clone(); + let session = if let Some(id) = cli.resume { + LocalSession::load(&id)? + } else if cli.r#continue { + session::list(Some(&cwd)).into_iter().next().unwrap_or_else(|| LocalSession::new(&cwd, "", "build", "auto")) + } else { + LocalSession::new(&cwd, "", "build", "auto") + }; + let mut session = session; + if session.model.is_empty() || env.catalog.find(&session.model).is_none() { + session.model = default_model(&env, &cfg, cli.model.clone()); + } else if let Some(m) = cli.model { + session.model = m; + } + if let Some(m) = cli.mode { + session.mode = m; + } + if session.messages.is_empty() { + session.permission_mode = cfg.permission_mode.clone().unwrap_or_else(|| "auto".into()); + } + // Notice when the server has a newer CLI. + let mut status = String::new(); + if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) { + if version_newer(v, env!("CARGO_PKG_VERSION")) { + status = format!("update available: {v} – run `codecraft update`"); } } - Ok(()) -} - -async fn job_id(api: &Api, id: &str) -> Result { - if id.len() >= 24 { - return Ok(id.to_string()); - } - let hits: Vec = api.get("/jobs").await?.as_array().into_iter().flatten().filter_map(|j| j["id"].as_str()).filter(|j| j.ends_with(id)).map(String::from).collect(); - match hits.len() { - 1 => Ok(hits[0].clone()), - 0 => bail!("no automation matches '{id}'"), - _ => bail!("'{id}' is ambiguous"), - } -} - -fn month_start_ms() -> i64 { - now_ms() - 30 * 86_400_000 -} - -fn truncate(s: &str, n: usize) -> String { - if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::()) } -} - -fn human(n: u64) -> String { - match n { - n if n < 1024 => format!("{n} B"), - n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0), - n if n < 1024 * 1024 * 1024 => format!("{:.1} MB", n as f64 / 1048576.0), - n => format!("{:.1} GB", n as f64 / 1073741824.0), - } -} - -fn urlencode(s: &str) -> String { - s.bytes() - .map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => (b as char).to_string(), - _ => format!("%{b:02X}"), - }) - .collect() + let (ui_tx, ui_rx) = mpsc::unbounded_channel(); + let cmd = spawn_agent(env.clone(), session.clone(), ui_tx); + let mut app = app::App::new(env, session, cmd); + app.set_status(status); + app::run(app, ui_rx, cli.prompt, setup).await } diff --git a/cli/src/models.rs b/cli/src/models.rs new file mode 100644 index 0000000..0ecac7f --- /dev/null +++ b/cli/src/models.rs @@ -0,0 +1,152 @@ +//! Model catalog: the server's models ("server:provider/model") and the user's +//! own providers ("provider/model"), and streaming requests to either. + +use crate::conf::Config; +use crate::server::Server; +use anyhow::{Result, anyhow}; +use codecraft_core::config::ModelConfig; +use codecraft_core::provider::{ChatRequest, StreamEvent, ToolDef}; +use codecraft_core::types::{Message, Usage}; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub const SERVER_PREFIX: &str = "server:"; + +#[derive(Debug, Clone)] +pub struct ModelInfo { + /// Selection key, e.g. "server:gpu/qwen-coder" or "ollama/llama3". + pub spec: String, + pub label: String, + pub config: ModelConfig, +} + +impl ModelInfo { + pub fn cost(&self, u: &Usage) -> f64 { + self.config.cost(u) + } +} + +#[derive(Default, Clone)] +pub struct Catalog { + pub models: Vec, + pub currency: String, +} + +fn model_from_json(v: &Value) -> ModelConfig { + serde_json::from_value(v.clone()).unwrap_or_else(|_| ModelConfig { id: v["id"].as_str().unwrap_or("").into(), ..Default::default() }) +} + +impl Catalog { + /// Collect models from the server config (if signed in) and local providers. + pub async fn load(cfg: &Config, server_cfg: Option<&Value>) -> Self { + let mut models = Vec::new(); + let mut currency = String::new(); + if let Some(sc) = server_cfg { + currency = sc["currency"].as_str().unwrap_or("").to_string(); + for p in sc["models"].as_array().into_iter().flatten() { + let provider = p["provider"].as_str().unwrap_or(""); + for m in p["models"].as_array().into_iter().flatten() { + let config = model_from_json(m); + let name = m["name"].as_str().unwrap_or(&config.id).to_string(); + models.push(ModelInfo { spec: format!("{SERVER_PREFIX}{provider}/{}", config.id), label: format!("server · {provider} · {name}"), config }); + } + } + } + for (name, pc) in &cfg.providers { + if !pc.enabled { + continue; + } + let mut list: Vec = pc.models.iter().map(|m| m.resolved()).collect(); + if list.is_empty() { + // Discover (e.g. Ollama, LM Studio) – quick, failures leave the list empty. + let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc); + if let Ok(Ok(found)) = tokio::time::timeout(std::time::Duration::from_secs(5), provider.list_models()).await { + list = found; + } + } + for m in list { + let label = format!("{name} · {}", m.name.clone().unwrap_or_else(|| m.id.clone())); + models.push(ModelInfo { spec: format!("{name}/{}", m.id), label, config: m }); + } + } + Self { models, currency } + } + + pub fn find(&self, spec: &str) -> Option<&ModelInfo> { + self.models.iter().find(|m| m.spec == spec) + } +} + +/// Stream one model request to the right backend. +#[allow(clippy::too_many_arguments)] +pub async fn stream( + cfg: &Config, + server: Option<&Server>, + model: &ModelInfo, + system: String, + messages: Vec, + tools: Vec, + effort: Option, + session: &str, + agent: &str, + tx: mpsc::Sender, + cancel: CancellationToken, +) -> Result<()> { + if let Some(rest) = model.spec.strip_prefix(SERVER_PREFIX) { + let server = server.ok_or_else(|| anyhow!("not signed in to a CodeCraft server (codecraft login)"))?; + return server.stream(rest, system, messages, tools, effort, session, agent, tx, cancel).await; + } + let (pname, _) = model.spec.split_once('/').ok_or_else(|| anyhow!("invalid model '{}'", model.spec))?; + let pc = cfg.providers.get(pname).ok_or_else(|| anyhow!("unknown provider '{pname}' – run /setup"))?; + let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc); + let mut mc = model.config.clone(); + if let Some(e) = effort { + mc.effort = Some(e); + } + let max_tokens = mc.max_output.unwrap_or(16_000); + let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true }; + provider.stream(req, tx, cancel).await +} + +/// Known endpoints for the setup wizard. +pub struct Preset { + pub id: &'static str, + pub label: &'static str, + pub kind: codecraft_core::config::ProviderKind, + pub base_url: &'static str, + pub needs_key: bool, + pub key_hint: &'static str, +} + +pub fn presets() -> Vec { + use codecraft_core::config::ProviderKind::*; + vec![ + Preset { id: "anthropic", label: "Anthropic (Claude)", kind: Anthropic, base_url: "", needs_key: true, key_hint: "sk-ant-… from console.anthropic.com" }, + Preset { id: "openai", label: "OpenAI", kind: Openai, base_url: "https://api.openai.com/v1", needs_key: true, key_hint: "sk-… from platform.openai.com" }, + Preset { id: "gemini", label: "Google Gemini", kind: Openai, base_url: "https://generativelanguage.googleapis.com/v1beta/openai", needs_key: true, key_hint: "key from aistudio.google.com" }, + Preset { id: "openrouter", label: "OpenRouter", kind: Openai, base_url: "https://openrouter.ai/api/v1", needs_key: true, key_hint: "sk-or-… from openrouter.ai" }, + Preset { id: "opencode", label: "opencode Zen / Go", kind: Openai, base_url: "https://opencode.ai/zen/v1", needs_key: true, key_hint: "key from opencode.ai" }, + Preset { id: "ollama", label: "Ollama (local)", kind: Openai, base_url: "http://localhost:11434/v1", needs_key: false, key_hint: "" }, + Preset { id: "lmstudio", label: "LM Studio (local)", kind: Openai, base_url: "http://localhost:1234/v1", needs_key: false, key_hint: "" }, + Preset { id: "llamacpp", label: "llama.cpp / vLLM (local)", kind: Openai, base_url: "http://localhost:8080/v1", needs_key: false, key_hint: "" }, + Preset { id: "custom", label: "Other OpenAI-compatible endpoint", kind: Openai, base_url: "", needs_key: false, key_hint: "optional" }, + ] +} + +/// Local model servers that answer right now (for the wizard). +pub async fn detect_local() -> Vec<&'static str> { + let mut found = Vec::new(); + for p in presets().into_iter().filter(|p| p.base_url.starts_with("http://localhost")) { + let ok = codecraft_core::provider::http_client() + .get(format!("{}/models", p.base_url)) + .timeout(std::time::Duration::from_millis(800)) + .send() + .await + .is_ok_and(|r| r.status().is_success()); + if ok { + found.push(p.id); + } + } + found +} diff --git a/cli/src/palettes.json b/cli/src/palettes.json new file mode 100644 index 0000000..6e10ae2 --- /dev/null +++ b/cli/src/palettes.json @@ -0,0 +1,812 @@ +{ +"ash-dark": { +"accent": "#e0914f", +"accent2": "#d6b46a", +"aqua": "#8aa89a", +"bg0": "#1c1b1f", +"bg1": "#232226", +"bg2": "#29282c", +"bg3": "#3a383d", +"blue": "#7c9aa1", +"fg0": "#f3ede1", +"fg1": "#e6dfd1", +"fg2": "#cfc7b8", +"fg3": "#b0a89b", +"green": "#8f9a5c", +"muted": "#999187", +"on-accent": "#1c1b1f", +"purple": "#b47e99", +"red": "#c15c46", +"syntax-comment": "#6d6a63", +"syntax-function": "#7c9aa1", +"syntax-keyword": "#c15c46", +"syntax-meta": "#e0914f", +"syntax-number": "#a585ac", +"syntax-string": "#8f9a5c", +"syntax-type": "#d6b46a", +"syntax-variable": "#e6dfd1" +}, +"ash-light": { +"accent": "#a85f1d", +"accent2": "#8a6a24", +"aqua": "#3d6b72", +"bg0": "#e9e3d5", +"bg1": "#dcd5c3", +"bg2": "#d2cab7", +"bg3": "#c9c1ad", +"blue": "#3d6b72", +"fg0": "#1e1c18", +"fg1": "#2a2822", +"fg2": "#433f37", +"fg3": "#5a554b", +"green": "#5c6631", +"muted": "#6f685c", +"on-accent": "#ffffff", +"purple": "#7c4a66", +"red": "#96392a", +"syntax-comment": "#8a8274", +"syntax-function": "#3d6b72", +"syntax-keyword": "#96392a", +"syntax-meta": "#a85f1d", +"syntax-number": "#6a4c72", +"syntax-string": "#5c6631", +"syntax-type": "#8a6a24", +"syntax-variable": "#2a2822" +}, +"catppuccin-dark": { +"accent": "#cba6f7", +"accent2": "#fab387", +"aqua": "#94e2d5", +"bg0": "#1e1e2e", +"bg1": "#181825", +"bg2": "#313244", +"bg3": "#45475a", +"blue": "#89b4fa", +"fg0": "#cdd6f4", +"fg1": "#cdd6f4", +"fg2": "#bac2de", +"fg3": "#a6adc8", +"green": "#a6e3a1", +"muted": "#9399b2", +"on-accent": "#1e1e2e", +"purple": "#f5c2e7", +"red": "#f38ba8", +"syntax-comment": "#7f849c", +"syntax-function": "#89b4fa", +"syntax-keyword": "#cba6f7", +"syntax-meta": "#f5c2e7", +"syntax-number": "#fab387", +"syntax-string": "#a6e3a1", +"syntax-type": "#f9e2af", +"syntax-variable": "#cdd6f4" +}, +"catppuccin-light": { +"accent": "#8839ef", +"accent2": "#fe640b", +"aqua": "#179299", +"bg0": "#eff1f5", +"bg1": "#e6e9ef", +"bg2": "#ccd0da", +"bg3": "#bcc0cc", +"blue": "#1e66f5", +"fg0": "#4c4f69", +"fg1": "#4c4f69", +"fg2": "#5c5f77", +"fg3": "#6c6f85", +"green": "#40a02b", +"muted": "#6c6f85", +"on-accent": "#ffffff", +"purple": "#ea76cb", +"red": "#d20f39", +"syntax-comment": "#8c8fa1", +"syntax-function": "#1e66f5", +"syntax-keyword": "#8839ef", +"syntax-meta": "#ea76cb", +"syntax-number": "#fe640b", +"syntax-string": "#40a02b", +"syntax-type": "#df8e1d", +"syntax-variable": "#4c4f69" +}, +"coffee-dark": { +"accent": "#e0a15c", +"accent2": "#ffda92", +"aqua": "#9fb8a8", +"bg0": "#1d150f", +"bg1": "#2d2118", +"bg2": "#3a2a20", +"bg3": "#4a372a", +"blue": "#a3b4c7", +"fg0": "#f8eee0", +"fg1": "#f0e2d0", +"fg2": "#d8c6b0", +"fg3": "#b09a84", +"green": "#b5b27a", +"muted": "#8f7a67", +"on-accent": "#1d150f", +"purple": "#cf90a2", +"red": "#d78a6f", +"syntax-comment": "#8f7a67", +"syntax-function": "#e0a15c", +"syntax-keyword": "#d78a6f", +"syntax-meta": "#ec8f89", +"syntax-number": "#cf90a2", +"syntax-string": "#ffda92", +"syntax-type": "#ec8f89", +"syntax-variable": "#f0e2d0" +}, +"coffee-light": { +"accent": "#8e2d53", +"accent2": "#b25d76", +"aqua": "#3f6b5e", +"bg0": "#e8e3c3", +"bg1": "#ddd8b8", +"bg2": "#d1ccad", +"bg3": "#c5c1a6", +"blue": "#8e2d53", +"fg0": "#1f1013", +"fg1": "#2d1a1d", +"fg2": "#45302f", +"fg3": "#5d4744", +"green": "#4f6b00", +"muted": "#6e5a55", +"on-accent": "#ffffff", +"purple": "#7a003b", +"red": "#a4405e", +"syntax-comment": "#8a7d70", +"syntax-function": "#8e2d53", +"syntax-keyword": "#a4405e", +"syntax-meta": "#b25d76", +"syntax-number": "#7a003b", +"syntax-string": "#4f6b00", +"syntax-type": "#b25d76", +"syntax-variable": "#2d1a1d" +}, +"dracula-dark": { +"accent": "#bd93f9", +"accent2": "#ffb86c", +"aqua": "#8be9fd", +"bg0": "#282a36", +"bg1": "#21222c", +"bg2": "#343746", +"bg3": "#44475a", +"blue": "#8be9fd", +"fg0": "#f8f8f2", +"fg1": "#f8f8f2", +"fg2": "#e2e2dc", +"fg3": "#c0c0ba", +"green": "#50fa7b", +"muted": "#9aa3c9", +"on-accent": "#282a36", +"purple": "#ff79c6", +"red": "#ff5555", +"syntax-comment": "#6272a4", +"syntax-function": "#50fa7b", +"syntax-keyword": "#ff79c6", +"syntax-meta": "#ffb86c", +"syntax-number": "#bd93f9", +"syntax-string": "#f1fa8c", +"syntax-type": "#8be9fd", +"syntax-variable": "#f8f8f2" +}, +"dracula-light": { +"accent": "#644ac9", +"accent2": "#a34d14", +"aqua": "#036a96", +"bg0": "#fffbeb", +"bg1": "#f5f1dc", +"bg2": "#efeddc", +"bg3": "#cfcfde", +"blue": "#036a96", +"fg0": "#1f1f1f", +"fg1": "#1f1f1f", +"fg2": "#3a3a3a", +"fg3": "#6c664b", +"green": "#14710a", +"muted": "#6c664b", +"on-accent": "#ffffff", +"purple": "#a3144d", +"red": "#cb3a2a", +"syntax-comment": "#6c664b", +"syntax-function": "#14710a", +"syntax-keyword": "#a3144d", +"syntax-meta": "#a34d14", +"syntax-number": "#644ac9", +"syntax-string": "#846e15", +"syntax-type": "#036a96", +"syntax-variable": "#1f1f1f" +}, +"everforest-dark": { +"accent": "#a7c080", +"accent2": "#e69875", +"aqua": "#83c092", +"bg0": "#2d353b", +"bg1": "#343f44", +"bg2": "#3d484d", +"bg3": "#475258", +"blue": "#7fbbb3", +"fg0": "#e5dcc4", +"fg1": "#d3c6aa", +"fg2": "#c0b597", +"fg3": "#9da9a0", +"green": "#a7c080", +"muted": "#859289", +"on-accent": "#2d353b", +"purple": "#d699b6", +"red": "#e67e80", +"syntax-comment": "#859289", +"syntax-function": "#a7c080", +"syntax-keyword": "#e67e80", +"syntax-meta": "#e69875", +"syntax-number": "#d699b6", +"syntax-string": "#dbbc7f", +"syntax-type": "#7fbbb3", +"syntax-variable": "#d3c6aa" +}, +"everforest-light": { +"accent": "#8da101", +"accent2": "#f57d26", +"aqua": "#35a77c", +"bg0": "#fdf6e3", +"bg1": "#f4f0d9", +"bg2": "#efebd4", +"bg3": "#e0dcc7", +"blue": "#3a94c5", +"fg0": "#4a565c", +"fg1": "#5c6a72", +"fg2": "#66757c", +"fg3": "#748086", +"green": "#8da101", +"muted": "#7a8478", +"on-accent": "#2d353b", +"purple": "#df69ba", +"red": "#f85552", +"syntax-comment": "#939f91", +"syntax-function": "#8da101", +"syntax-keyword": "#f85552", +"syntax-meta": "#f57d26", +"syntax-number": "#df69ba", +"syntax-string": "#dfa000", +"syntax-type": "#3a94c5", +"syntax-variable": "#5c6a72" +}, +"github-dark": { +"accent": "#4493f8", +"accent2": "#d29922", +"aqua": "#39c5cf", +"bg0": "#0d1117", +"bg1": "#161b22", +"bg2": "#21262d", +"bg3": "#30363d", +"blue": "#58a6ff", +"fg0": "#f0f6fc", +"fg1": "#e6edf3", +"fg2": "#c9d1d9", +"fg3": "#9198a1", +"green": "#3fb950", +"muted": "#8b949e", +"on-accent": "#0d1117", +"purple": "#bc8cff", +"red": "#f85149", +"syntax-comment": "#8b949e", +"syntax-function": "#d2a8ff", +"syntax-keyword": "#ff7b72", +"syntax-meta": "#7ee787", +"syntax-number": "#79c0ff", +"syntax-string": "#a5d6ff", +"syntax-type": "#ffa657", +"syntax-variable": "#e6edf3" +}, +"github-light": { +"accent": "#0969da", +"accent2": "#9a6700", +"aqua": "#1b7c83", +"bg0": "#ffffff", +"bg1": "#f6f8fa", +"bg2": "#eaeef2", +"bg3": "#d0d7de", +"blue": "#0969da", +"fg0": "#1f2328", +"fg1": "#1f2328", +"fg2": "#31363c", +"fg3": "#59636e", +"green": "#1a7f37", +"muted": "#59636e", +"on-accent": "#ffffff", +"purple": "#8250df", +"red": "#d1242f", +"syntax-comment": "#59636e", +"syntax-function": "#8250df", +"syntax-keyword": "#cf222e", +"syntax-meta": "#116329", +"syntax-number": "#0550ae", +"syntax-string": "#0a3069", +"syntax-type": "#953800", +"syntax-variable": "#1f2328" +}, +"gruvbox-dark": { +"accent": "#fabd2f", +"accent2": "#fe8019", +"aqua": "#8ec07c", +"bg0": "#282828", +"bg1": "#32302f", +"bg2": "#3c3836", +"bg3": "#504945", +"blue": "#83a598", +"fg0": "#fbf1c7", +"fg1": "#ebdbb2", +"fg2": "#d5c4a1", +"fg3": "#a89984", +"green": "#b8bb26", +"muted": "#928374", +"on-accent": "#282828", +"purple": "#d3869b", +"red": "#fb4934", +"syntax-comment": "#928374", +"syntax-function": "#8ec07c", +"syntax-keyword": "#fb4934", +"syntax-meta": "#fe8019", +"syntax-number": "#d3869b", +"syntax-string": "#b8bb26", +"syntax-type": "#fabd2f", +"syntax-variable": "#83a598" +}, +"gruvbox-light": { +"accent": "#b57614", +"accent2": "#af3a03", +"aqua": "#427b58", +"bg0": "#fbf1c7", +"bg1": "#f2e5bc", +"bg2": "#ebdbb2", +"bg3": "#d5c4a1", +"blue": "#076678", +"fg0": "#282828", +"fg1": "#3c3836", +"fg2": "#504945", +"fg3": "#665c54", +"green": "#79740e", +"muted": "#7c6f64", +"on-accent": "#282828", +"purple": "#8f3f71", +"red": "#9d0006", +"syntax-comment": "#928374", +"syntax-function": "#427b58", +"syntax-keyword": "#9d0006", +"syntax-meta": "#af3a03", +"syntax-number": "#8f3f71", +"syntax-string": "#79740e", +"syntax-type": "#b57614", +"syntax-variable": "#076678" +}, +"harbor-dark": { +"accent": "#e75a50", +"accent2": "#e1ce98", +"aqua": "#8a9ba6", +"bg0": "#1b1b1b", +"bg1": "#232323", +"bg2": "#2e2e2e", +"bg3": "#3f3f3f", +"blue": "#77838a", +"fg0": "#f7f4ea", +"fg1": "#efebdc", +"fg2": "#d6d1c0", +"fg3": "#c0b79f", +"green": "#a99b7a", +"muted": "#a99b7a", +"on-accent": "#1b1b1b", +"purple": "#e58980", +"red": "#f44336", +"syntax-comment": "#77838a", +"syntax-function": "#a99b7a", +"syntax-keyword": "#e75a50", +"syntax-meta": "#e58980", +"syntax-number": "#e58980", +"syntax-string": "#e1ce98", +"syntax-type": "#e1ce98", +"syntax-variable": "#efebdc" +}, +"harbor-light": { +"accent": "#4c6c94", +"accent2": "#dc8164", +"aqua": "#3d727d", +"bg0": "#dfe4c4", +"bg1": "#d0d6b4", +"bg2": "#c3c9a6", +"bg3": "#b3b994", +"blue": "#4c6c94", +"fg0": "#101c18", +"fg1": "#1c2d28", +"fg2": "#2f413b", +"fg3": "#435650", +"green": "#556753", +"muted": "#556753", +"on-accent": "#ffffff", +"purple": "#8a5b81", +"red": "#b14752", +"syntax-comment": "#6f7d6c", +"syntax-function": "#3d727d", +"syntax-keyword": "#b14752", +"syntax-meta": "#b06247", +"syntax-number": "#8a5b81", +"syntax-string": "#556753", +"syntax-type": "#4c6c94", +"syntax-variable": "#1c2d28" +}, +"kanagawa-dark": { +"accent": "#7e9cd8", +"accent2": "#ffa066", +"aqua": "#7aa89f", +"bg0": "#1f1f28", +"bg1": "#16161d", +"bg2": "#2a2a37", +"bg3": "#363646", +"blue": "#7fb4ca", +"fg0": "#dcd7ba", +"fg1": "#dcd7ba", +"fg2": "#c8c093", +"fg3": "#a6a69c", +"green": "#98bb6c", +"muted": "#8f8e85", +"on-accent": "#1f1f28", +"purple": "#957fb8", +"red": "#e46876", +"syntax-comment": "#727169", +"syntax-function": "#7e9cd8", +"syntax-keyword": "#957fb8", +"syntax-meta": "#ffa066", +"syntax-number": "#d27e99", +"syntax-string": "#98bb6c", +"syntax-type": "#7aa89f", +"syntax-variable": "#e6c384" +}, +"kanagawa-light": { +"accent": "#4d699b", +"accent2": "#cc6d00", +"aqua": "#597b75", +"bg0": "#f2ecbc", +"bg1": "#e5ddb0", +"bg2": "#dcd5ac", +"bg3": "#d5cea3", +"blue": "#4d699b", +"fg0": "#43436c", +"fg1": "#545464", +"fg2": "#5f5f72", +"fg3": "#6d6d7c", +"green": "#6f894e", +"muted": "#716e61", +"on-accent": "#ffffff", +"purple": "#624c83", +"red": "#c84053", +"syntax-comment": "#8a8980", +"syntax-function": "#4d699b", +"syntax-keyword": "#624c83", +"syntax-meta": "#cc6d00", +"syntax-number": "#b35b79", +"syntax-string": "#6f894e", +"syntax-type": "#597b75", +"syntax-variable": "#545464" +}, +"nord-dark": { +"accent": "#88c0d0", +"accent2": "#d08770", +"aqua": "#8fbcbb", +"bg0": "#2e3440", +"bg1": "#3b4252", +"bg2": "#434c5e", +"bg3": "#4c566a", +"blue": "#81a1c1", +"fg0": "#eceff4", +"fg1": "#e5e9f0", +"fg2": "#d8dee9", +"fg3": "#b8c0cf", +"green": "#a3be8c", +"muted": "#8a93a5", +"on-accent": "#2e3440", +"purple": "#b48ead", +"red": "#bf616a", +"syntax-comment": "#616e88", +"syntax-function": "#88c0d0", +"syntax-keyword": "#81a1c1", +"syntax-meta": "#d08770", +"syntax-number": "#b48ead", +"syntax-string": "#a3be8c", +"syntax-type": "#8fbcbb", +"syntax-variable": "#d8dee9" +}, +"nord-light": { +"accent": "#5e81ac", +"accent2": "#b55f45", +"aqua": "#4d8a89", +"bg0": "#eceff4", +"bg1": "#e5e9f0", +"bg2": "#d8dee9", +"bg3": "#c2c9d6", +"blue": "#5e81ac", +"fg0": "#2e3440", +"fg1": "#3b4252", +"fg2": "#434c5e", +"fg3": "#4c566a", +"green": "#5f7d48", +"muted": "#5f6b80", +"on-accent": "#ffffff", +"purple": "#8f6a8a", +"red": "#a9434d", +"syntax-comment": "#7b88a1", +"syntax-function": "#4c7a90", +"syntax-keyword": "#5e81ac", +"syntax-meta": "#b55f45", +"syntax-number": "#8f6a8a", +"syntax-string": "#5f7d48", +"syntax-type": "#4d8a89", +"syntax-variable": "#3b4252" +}, +"one-dark": { +"accent": "#61afef", +"accent2": "#d19a66", +"aqua": "#56b6c2", +"bg0": "#282c34", +"bg1": "#21252b", +"bg2": "#2c313a", +"bg3": "#3e4451", +"blue": "#61afef", +"fg0": "#d7dae0", +"fg1": "#abb2bf", +"fg2": "#9da5b4", +"fg3": "#8b929e", +"green": "#98c379", +"muted": "#7f848e", +"on-accent": "#282c34", +"purple": "#c678dd", +"red": "#e06c75", +"syntax-comment": "#7f848e", +"syntax-function": "#61afef", +"syntax-keyword": "#c678dd", +"syntax-meta": "#56b6c2", +"syntax-number": "#d19a66", +"syntax-string": "#98c379", +"syntax-type": "#e5c07b", +"syntax-variable": "#e06c75" +}, +"one-light": { +"accent": "#4078f2", +"accent2": "#c18401", +"aqua": "#0184bc", +"bg0": "#fafafa", +"bg1": "#f0f0f1", +"bg2": "#e5e5e6", +"bg3": "#d4d4d6", +"blue": "#4078f2", +"fg0": "#232324", +"fg1": "#383a42", +"fg2": "#4f525e", +"fg3": "#696c77", +"green": "#50a14f", +"muted": "#696c77", +"on-accent": "#ffffff", +"purple": "#a626a4", +"red": "#e45649", +"syntax-comment": "#a0a1a7", +"syntax-function": "#4078f2", +"syntax-keyword": "#a626a4", +"syntax-meta": "#0184bc", +"syntax-number": "#986801", +"syntax-string": "#50a14f", +"syntax-type": "#c18401", +"syntax-variable": "#e45649" +}, +"rosepine-dark": { +"accent": "#c4a7e7", +"accent2": "#ebbcba", +"aqua": "#9ccfd8", +"bg0": "#191724", +"bg1": "#1f1d2e", +"bg2": "#26233a", +"bg3": "#403d52", +"blue": "#31748f", +"fg0": "#e0def4", +"fg1": "#e0def4", +"fg2": "#c9c6e0", +"fg3": "#908caa", +"green": "#9ccfd8", +"muted": "#908caa", +"on-accent": "#191724", +"purple": "#c4a7e7", +"red": "#eb6f92", +"syntax-comment": "#6e6a86", +"syntax-function": "#ebbcba", +"syntax-keyword": "#31748f", +"syntax-meta": "#c4a7e7", +"syntax-number": "#f6c177", +"syntax-string": "#f6c177", +"syntax-type": "#9ccfd8", +"syntax-variable": "#e0def4" +}, +"rosepine-light": { +"accent": "#286983", +"accent2": "#d7827e", +"aqua": "#56949f", +"bg0": "#faf4ed", +"bg1": "#f2e9e1", +"bg2": "#dfdad9", +"bg3": "#cecacd", +"blue": "#286983", +"fg0": "#464261", +"fg1": "#575279", +"fg2": "#6a6585", +"fg3": "#797593", +"green": "#56949f", +"muted": "#797593", +"on-accent": "#ffffff", +"purple": "#907aa9", +"red": "#b4637a", +"syntax-comment": "#9893a5", +"syntax-function": "#d7827e", +"syntax-keyword": "#286983", +"syntax-meta": "#907aa9", +"syntax-number": "#ea9d34", +"syntax-string": "#ea9d34", +"syntax-type": "#56949f", +"syntax-variable": "#575279" +}, +"solarized-dark": { +"accent": "#b58900", +"accent2": "#cb4b16", +"aqua": "#2aa198", +"bg0": "#002b36", +"bg1": "#073642", +"bg2": "#0b4250", +"bg3": "#155565", +"blue": "#268bd2", +"fg0": "#eee8d5", +"fg1": "#93a1a1", +"fg2": "#839496", +"fg3": "#839496", +"green": "#859900", +"muted": "#6c8288", +"on-accent": "#002b36", +"purple": "#6c71c4", +"red": "#dc322f", +"syntax-comment": "#586e75", +"syntax-function": "#268bd2", +"syntax-keyword": "#859900", +"syntax-meta": "#cb4b16", +"syntax-number": "#d33682", +"syntax-string": "#2aa198", +"syntax-type": "#b58900", +"syntax-variable": "#93a1a1" +}, +"solarized-light": { +"accent": "#b58900", +"accent2": "#cb4b16", +"aqua": "#2aa198", +"bg0": "#fdf6e3", +"bg1": "#eee8d5", +"bg2": "#e4dcc4", +"bg3": "#d6ceb5", +"blue": "#268bd2", +"fg0": "#073642", +"fg1": "#586e75", +"fg2": "#657b83", +"fg3": "#657b83", +"green": "#859900", +"muted": "#6f8186", +"on-accent": "#002b36", +"purple": "#6c71c4", +"red": "#dc322f", +"syntax-comment": "#93a1a1", +"syntax-function": "#268bd2", +"syntax-keyword": "#859900", +"syntax-meta": "#cb4b16", +"syntax-number": "#d33682", +"syntax-string": "#2aa198", +"syntax-type": "#b58900", +"syntax-variable": "#586e75" +}, +"tokyonight-dark": { +"accent": "#7aa2f7", +"accent2": "#ff9e64", +"aqua": "#73daca", +"bg0": "#24283b", +"bg1": "#1f2335", +"bg2": "#292e42", +"bg3": "#3b4261", +"blue": "#7aa2f7", +"fg0": "#c0caf5", +"fg1": "#c0caf5", +"fg2": "#a9b1d6", +"fg3": "#9aa5ce", +"green": "#9ece6a", +"muted": "#737aa2", +"on-accent": "#24283b", +"purple": "#bb9af7", +"red": "#f7768e", +"syntax-comment": "#565f89", +"syntax-function": "#7aa2f7", +"syntax-keyword": "#bb9af7", +"syntax-meta": "#e0af68", +"syntax-number": "#ff9e64", +"syntax-string": "#9ece6a", +"syntax-type": "#2ac3de", +"syntax-variable": "#7dcfff" +}, +"tokyonight-light": { +"accent": "#2e7de9", +"accent2": "#b15c00", +"aqua": "#118c74", +"bg0": "#e1e2e7", +"bg1": "#d0d5e3", +"bg2": "#c4c8da", +"bg3": "#a8aecb", +"blue": "#2e7de9", +"fg0": "#273463", +"fg1": "#3760bf", +"fg2": "#4b5a99", +"fg3": "#6172b0", +"green": "#587539", +"muted": "#6c74a0", +"on-accent": "#ffffff", +"purple": "#9854f1", +"red": "#f52a65", +"syntax-comment": "#848cb5", +"syntax-function": "#2e7de9", +"syntax-keyword": "#9854f1", +"syntax-meta": "#8c6c3e", +"syntax-number": "#b15c00", +"syntax-string": "#587539", +"syntax-type": "#007197", +"syntax-variable": "#3760bf" +}, +"vscode-dark": { +"accent": "#0078d4", +"accent2": "#ce9178", +"aqua": "#4ec9b0", +"bg0": "#1f1f1f", +"bg1": "#181818", +"bg2": "#2b2b2b", +"bg3": "#3c3c3c", +"blue": "#3794ff", +"fg0": "#ffffff", +"fg1": "#cccccc", +"fg2": "#bbbbbb", +"fg3": "#9d9d9d", +"green": "#89d185", +"muted": "#8b8b8b", +"on-accent": "#ffffff", +"purple": "#c586c0", +"red": "#f14c4c", +"syntax-comment": "#6a9955", +"syntax-function": "#dcdcaa", +"syntax-keyword": "#569cd6", +"syntax-meta": "#c586c0", +"syntax-number": "#b5cea8", +"syntax-string": "#ce9178", +"syntax-type": "#4ec9b0", +"syntax-variable": "#9cdcfe" +}, +"vscode-light": { +"accent": "#005fb8", +"accent2": "#a31515", +"aqua": "#267f99", +"bg0": "#ffffff", +"bg1": "#f8f8f8", +"bg2": "#e5e5e5", +"bg3": "#cecece", +"blue": "#005fb8", +"fg0": "#000000", +"fg1": "#3b3b3b", +"fg2": "#444444", +"fg3": "#616161", +"green": "#388a34", +"muted": "#6e7681", +"on-accent": "#ffffff", +"purple": "#af00db", +"red": "#cd3131", +"syntax-comment": "#008000", +"syntax-function": "#795e26", +"syntax-keyword": "#0000ff", +"syntax-meta": "#af00db", +"syntax-number": "#098658", +"syntax-string": "#a31515", +"syntax-type": "#267f99", +"syntax-variable": "#001080" +} +} \ No newline at end of file diff --git a/cli/src/server.rs b/cli/src/server.rs new file mode 100644 index 0000000..903df75 --- /dev/null +++ b/cli/src/server.rs @@ -0,0 +1,138 @@ +//! Connection to a CodeCraft server: login, configuration, model gateway, MCP tools. + +use crate::conf::ServerLogin; +use anyhow::{Context, Result, anyhow, bail}; +use codecraft_core::provider::{StreamEvent, ToolDef}; +use codecraft_core::types::{Message, Usage}; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use serde_json::{Value, json}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub struct Server { + pub url: String, + token: String, + http: reqwest::Client, +} + +pub fn http() -> reqwest::Client { + reqwest::Client::builder() + .user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(std::time::Duration::from_secs(15)) + .build() + .expect("http client") +} + +async fn check(resp: reqwest::Response) -> Result { + if resp.status().is_success() { + return Ok(resp); + } + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let msg = serde_json::from_str::(&body).ok().and_then(|v| v["error"].as_str().map(String::from)).unwrap_or(body); + match status.as_u16() { + 401 => bail!("the server rejected the login – run `codecraft login` again"), + _ => bail!("server: {} {}", status.as_u16(), msg.trim()), + } +} + +/// Sign in with username and password; the server creates an API token for this machine. +pub async fn login(url: &str, username: &str, password: &str) -> Result { + let url = url.trim().trim_end_matches('/').to_string(); + let url = if url.starts_with("http://") || url.starts_with("https://") { url } else { format!("https://{url}") }; + let host = hostname(); + let resp = http() + .post(format!("{url}/api/auth/cli-login")) + .json(&json!({"username": username, "password": password, "hostname": host})) + .send() + .await + .with_context(|| format!("cannot reach {url}"))?; + let v: Value = check(resp).await?.json().await?; + let token = v["token"].as_str().ok_or_else(|| anyhow!("unexpected answer from the server"))?.to_string(); + Ok(ServerLogin { url, token, username: v["user"]["username"].as_str().unwrap_or(username).to_string() }) +} + +pub fn hostname() -> String { + std::env::var("HOSTNAME") + .ok() + .or_else(|| std::fs::read_to_string("/etc/hostname").ok()) + .or_else(|| std::env::var("COMPUTERNAME").ok()) + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "terminal".into()) +} + +impl Server { + pub fn new(login: &ServerLogin) -> Self { + Self { url: login.url.clone(), token: login.token.clone(), http: http() } + } + + pub async fn get(&self, path: &str) -> Result { + let resp = self.http.get(format!("{}/api{path}", self.url)).bearer_auth(&self.token).send().await.with_context(|| format!("cannot reach {}", self.url))?; + Ok(check(resp).await?.json().await?) + } + + pub async fn post(&self, path: &str, body: Value) -> Result { + let resp = self.http.post(format!("{}/api{path}", self.url)).bearer_auth(&self.token).json(&body).send().await.with_context(|| format!("cannot reach {}", self.url))?; + Ok(check(resp).await?.json().await?) + } + + /// One model request through the server's gateway, as stream events. + #[allow(clippy::too_many_arguments)] + pub async fn stream( + &self, + model: &str, + system: String, + messages: Vec, + tools: Vec, + effort: Option, + session: &str, + agent: &str, + tx: mpsc::Sender, + cancel: CancellationToken, + ) -> Result<()> { + let body = json!({ + "model": model, "system": system, "messages": messages, "tools": tools, + "reasoning": true, "effort": effort, "session": session, "agent": agent, + }); + let resp = self.http.post(format!("{}/api/llm/stream", self.url)).bearer_auth(&self.token).json(&body).send().await.with_context(|| format!("cannot reach {}", self.url))?; + let resp = check(resp).await?; + let mut events = resp.bytes_stream().eventsource(); + loop { + let ev = tokio::select! { + ev = events.next() => ev, + _ = cancel.cancelled() => return Ok(()), + }; + let Some(ev) = ev else { break }; + let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?; + let v: Value = serde_json::from_str(&ev.data).unwrap_or(Value::Null); + let out = match v["type"].as_str().unwrap_or("") { + "text" => StreamEvent::TextDelta(v["text"].as_str().unwrap_or("").into()), + "reasoning" => StreamEvent::ReasoningDelta(v["text"].as_str().unwrap_or("").into()), + "signature" => StreamEvent::ReasoningSignature(v["signature"].as_str().unwrap_or("").into()), + "tool_call" => StreamEvent::ToolCall { + id: v["id"].as_str().unwrap_or("").into(), + name: v["name"].as_str().unwrap_or("").into(), + input: v["input"].clone(), + extra: v.get("extra").filter(|e| !e.is_null()).cloned(), + }, + "usage" => StreamEvent::Usage(serde_json::from_value::(v["usage"].clone()).unwrap_or_default()), + "done" => StreamEvent::Done { stop_reason: v["stop_reason"].as_str().unwrap_or("").into() }, + "error" => bail!("{}", v["message"].as_str().unwrap_or("model error")), + _ => continue, + }; + if tx.send(out).await.is_err() { + break; + } + } + Ok(()) + } + + /// Run one of the server's MCP tools. + pub async fn tool(&self, name: &str, input: Value) -> Result<(String, bool)> { + let v = self.post("/cli/tool", json!({"name": name, "input": input})).await?; + Ok((v["content"].as_str().unwrap_or("").to_string(), v["is_error"].as_bool().unwrap_or(false))) + } +} diff --git a/cli/src/session.rs b/cli/src/session.rs new file mode 100644 index 0000000..f7a9be4 --- /dev/null +++ b/cli/src/session.rs @@ -0,0 +1,90 @@ +//! Local sessions (~/.local/share/codecraft/sessions/.json). + +use anyhow::Result; +use codecraft_core::types::{Message, Part, Usage, new_id, now_ms}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalSession { + pub id: String, + pub title: String, + pub cwd: String, + pub model: String, + pub mode: String, + pub permission_mode: String, + pub created_at: i64, + pub updated_at: i64, + pub messages: Vec, + #[serde(default)] + pub usage: Usage, + #[serde(default)] + pub cost: f64, + #[serde(default)] + pub todos: Value, + /// Tokens of the last request (context fill). + #[serde(default)] + pub context_tokens: u64, +} + +fn dir() -> PathBuf { + crate::conf::data_dir().join("sessions") +} + +impl LocalSession { + pub fn new(cwd: &str, model: &str, mode: &str, permission_mode: &str) -> Self { + let now = now_ms(); + Self { + id: new_id(), + title: String::new(), + cwd: cwd.into(), + model: model.into(), + mode: mode.into(), + permission_mode: permission_mode.into(), + created_at: now, + updated_at: now, + messages: vec![], + usage: Usage::default(), + cost: 0.0, + todos: Value::Null, + context_tokens: 0, + } + } + + pub fn save(&mut self) -> Result<()> { + if self.messages.is_empty() { + return Ok(()); + } + self.updated_at = now_ms(); + std::fs::create_dir_all(dir())?; + let tmp = dir().join(format!("{}.json.tmp", self.id)); + std::fs::write(&tmp, serde_json::to_vec(self)?)?; + std::fs::rename(tmp, dir().join(format!("{}.json", self.id)))?; + Ok(()) + } + + pub fn load(id: &str) -> Result { + Ok(serde_json::from_slice(&std::fs::read(dir().join(format!("{id}.json")))?)?) + } + + /// Messages the model sees: everything from the last summary on. + pub fn active(&self) -> Vec { + let start = self.messages.iter().rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))).unwrap_or(0); + self.messages[start..].to_vec() + } +} + +/// Sessions (newest first), optionally only those of one folder. +pub fn list(cwd: Option<&str>) -> Vec { + let mut out: Vec = std::fs::read_dir(dir()) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .filter_map(|e| serde_json::from_slice::(&std::fs::read(e.path()).ok()?).ok()) + .filter(|s| cwd.is_none_or(|c| s.cwd == c)) + .collect(); + out.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); + out +} diff --git a/cli/src/stats.rs b/cli/src/stats.rs new file mode 100644 index 0000000..972115e --- /dev/null +++ b/cli/src/stats.rs @@ -0,0 +1,36 @@ +//! Local usage log (~/.local/share/codecraft/usage.jsonl) for /costs. + +use crate::session::LocalSession; +use codecraft_core::types::{Usage, now_ms}; +use serde_json::{Value, json}; +use std::io::Write; + +fn path() -> std::path::PathBuf { + crate::conf::data_dir().join("usage.jsonl") +} + +pub fn record(session: &LocalSession, model: &str, u: &Usage, cost: f64) { + let line = json!({"ts": now_ms(), "session": session.id, "model": model, "in": u.input_tokens, "out": u.output_tokens, "cache_read": u.cache_read_tokens, "cache_write": u.cache_write_tokens, "cost": cost}); + let _ = std::fs::create_dir_all(crate::conf::data_dir()); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path()) { + let _ = writeln!(f, "{line}"); + } +} + +/// (requests, input, output, cost) per model since `since` (ms). +pub fn summary(since: i64) -> Vec<(String, u64, u64, u64, f64)> { + let text = std::fs::read_to_string(path()).unwrap_or_default(); + let mut map: std::collections::BTreeMap = Default::default(); + for line in text.lines() { + let Ok(v) = serde_json::from_str::(line) else { continue }; + if v["ts"].as_i64().unwrap_or(0) < since { + continue; + } + let e = map.entry(v["model"].as_str().unwrap_or("?").to_string()).or_default(); + e.0 += 1; + e.1 += v["in"].as_u64().unwrap_or(0) + v["cache_read"].as_u64().unwrap_or(0) + v["cache_write"].as_u64().unwrap_or(0); + e.2 += v["out"].as_u64().unwrap_or(0); + e.3 += v["cost"].as_f64().unwrap_or(0.0); + } + map.into_iter().map(|(k, (n, i, o, c))| (k, n, i, o, c)).collect() +} diff --git a/cli/src/theme.rs b/cli/src/theme.rs new file mode 100644 index 0000000..04d6110 --- /dev/null +++ b/cli/src/theme.rs @@ -0,0 +1,102 @@ +//! Colours from the web themes (same palettes as the web UI, 24-bit). + +use ratatui::style::Color; +use serde_json::Value; +use std::sync::OnceLock; + +const PALETTES: &str = include_str!("palettes.json"); + +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub struct Theme { + pub bg0: Color, + pub bg1: Color, + pub bg2: Color, + pub bg3: Color, + pub fg0: Color, + pub fg1: Color, + pub fg2: Color, + pub fg3: Color, + pub muted: Color, + pub accent: Color, + pub accent2: Color, + pub on_accent: Color, + pub red: Color, + pub green: Color, + pub blue: Color, + pub aqua: Color, + pub purple: Color, + pub keyword: Color, + pub string: Color, + pub comment: Color, +} + +fn hex(v: &Value, key: &str, fallback: Color) -> Color { + v[key] + .as_str() + .and_then(|s| { + let s = s.trim_start_matches('#'); + Some(Color::Rgb(u8::from_str_radix(s.get(0..2)?, 16).ok()?, u8::from_str_radix(s.get(2..4)?, 16).ok()?, u8::from_str_radix(s.get(4..6)?, 16).ok()?)) + }) + .unwrap_or(fallback) +} + +pub fn names() -> Vec { + let all: Value = serde_json::from_str(PALETTES).unwrap_or(Value::Null); + let mut out: Vec = all.as_object().map(|o| o.keys().filter_map(|k| k.rsplit_once('-').map(|(b, _)| b.to_string())).collect()).unwrap_or_default(); + out.dedup(); + out +} + +/// Is the terminal background dark? (COLORFGBG, else assume dark) +fn terminal_dark() -> bool { + std::env::var("COLORFGBG").ok().and_then(|v| v.rsplit(';').next().and_then(|b| b.parse::().ok())).map(|bg| bg < 7 || bg == 8).unwrap_or(true) +} + +impl Theme { + /// `base` e.g. "gruvbox", `mode` "light" | "dark" | "auto". + pub fn load(base: &str, mode: &str) -> Self { + let dark = match mode { + "light" => false, + "dark" => true, + _ => terminal_dark(), + }; + let all: Value = serde_json::from_str(PALETTES).unwrap_or(Value::Null); + let key = format!("{base}-{}", if dark { "dark" } else { "light" }); + let p = all.get(&key).or_else(|| all.get(if dark { "gruvbox-dark" } else { "gruvbox-light" })).cloned().unwrap_or(Value::Null); + let d = |k: &str, f: Color| hex(&p, k, f); + Self { + bg0: d("bg0", Color::Reset), + bg1: d("bg1", Color::Reset), + bg2: d("bg2", Color::DarkGray), + bg3: d("bg3", Color::DarkGray), + fg0: d("fg0", Color::White), + fg1: d("fg1", Color::Reset), + fg2: d("fg2", Color::Gray), + fg3: d("fg3", Color::Gray), + muted: d("muted", Color::DarkGray), + accent: d("accent", Color::Yellow), + accent2: d("accent2", Color::LightYellow), + on_accent: d("on-accent", Color::Black), + red: d("red", Color::Red), + green: d("green", Color::Green), + blue: d("blue", Color::Blue), + aqua: d("aqua", Color::Cyan), + purple: d("purple", Color::Magenta), + keyword: d("syntax-keyword", Color::Red), + string: d("syntax-string", Color::Green), + comment: d("syntax-comment", Color::DarkGray), + } + } +} + +static CURRENT: OnceLock> = OnceLock::new(); + +pub fn set(t: Theme) { + let lock = CURRENT.get_or_init(|| std::sync::RwLock::new(t)); + *lock.write().unwrap() = t; +} + +pub fn get() -> Theme { + *CURRENT.get_or_init(|| std::sync::RwLock::new(Theme::load("gruvbox", "auto"))).read().unwrap() +} diff --git a/cli/src/tools.rs b/cli/src/tools.rs new file mode 100644 index 0000000..0ea4dc7 --- /dev/null +++ b/cli/src/tools.rs @@ -0,0 +1,568 @@ +//! Local tools: they run on this machine in the session's working directory. + +use crate::server::Server; +use anyhow::{Result, anyhow, bail}; +use base64::Engine; +use codecraft_core::permission::ToolInfo; +use codecraft_core::provider::ToolDef; +use codecraft_core::types::ImageData; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio_util::sync::CancellationToken; + +const MAX_OUTPUT: usize = 30_000; + +/// Built-in skills and commands (also available without a server). +pub const BUILTIN: &[(&str, &str)] = &[ + ("skills/cad/SKILL.md", include_str!("../../server/resources/skills/cad/SKILL.md")), + ("skills/recreate/SKILL.md", include_str!("../../server/resources/skills/recreate/SKILL.md")), + ("skills/recreate/templates/01-inventory.md", include_str!("../../server/resources/skills/recreate/templates/01-inventory.md")), + ("skills/recreate/templates/02-overview.md", include_str!("../../server/resources/skills/recreate/templates/02-overview.md")), + ("skills/recreate/templates/03-architecture.md", include_str!("../../server/resources/skills/recreate/templates/03-architecture.md")), + ("skills/recreate/templates/04-file-formats.md", include_str!("../../server/resources/skills/recreate/templates/04-file-formats.md")), + ("skills/recreate/templates/05-ui-and-behaviour.md", include_str!("../../server/resources/skills/recreate/templates/05-ui-and-behaviour.md")), + ("skills/recreate/templates/06-assets.md", include_str!("../../server/resources/skills/recreate/templates/06-assets.md")), + ("skills/recreate/templates/recreation-plan.md", include_str!("../../server/resources/skills/recreate/templates/recreation-plan.md")), + ("commands/cad.md", include_str!("../../server/resources/commands/cad.md")), + ("commands/recreate-init.md", include_str!("../../server/resources/commands/recreate-init.md")), + ("commands/recreate-start.md", include_str!("../../server/resources/commands/recreate-start.md")), +]; + +pub struct ToolSpec { + pub name: String, + pub description: String, + pub schema: Value, + pub read_only: bool, + pub is_edit: bool, + /// Executed by the server (its MCP tools). + pub remote: bool, +} + +impl ToolInfo for ToolSpec { + fn name(&self) -> &str { + &self.name + } + fn read_only(&self) -> bool { + self.read_only + } + fn is_edit(&self) -> bool { + self.is_edit + } +} + +impl ToolSpec { + pub fn def(&self) -> ToolDef { + ToolDef { name: self.name.clone(), description: self.description.clone(), input_schema: self.schema.clone() } + } +} + +fn spec(name: &str, description: &str, schema: Value, read_only: bool, is_edit: bool) -> ToolSpec { + ToolSpec { name: name.into(), description: description.into(), schema, read_only, is_edit, remote: false } +} + +/// The local tool set. +pub fn local_tools() -> Vec { + vec![ + spec("read", "Read a file (text with line numbers; images are shown to you). Use offset/limit (lines) for large files.", json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}), true, false), + spec("write", "Create or overwrite a file with the given content. Read existing files first.", json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}), false, true), + spec("edit", "Replace an exact text in a file. old_string must match exactly (including indentation) and be unique unless replace_all is true. Read the file first.", json!({"type":"object","properties":{"path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean"}},"required":["path","old_string","new_string"]}), false, true), + spec("list", "List a directory (respects .gitignore).", json!({"type":"object","properties":{"path":{"type":"string"}}}), true, false), + spec("glob", "Find files by glob pattern, e.g. \"**/*.rs\" (respects .gitignore).", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}), true, false), + spec("grep", "Search file contents with a regular expression (respects .gitignore). Returns file:line: text.", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"},"glob":{"type":"string"},"ignore_case":{"type":"boolean"}},"required":["pattern"]}), true, false), + spec("bash", "Run a shell command in the working directory (stdout and stderr combined). Default timeout 2 min, max 10 min (timeout_ms). For servers/watchers set run_in_background=true and read output with bash_output.", json!({"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"},"timeout_ms":{"type":"integer"},"run_in_background":{"type":"boolean"}},"required":["command"]}), false, false), + spec("bash_output", "Read new output of a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), true, false), + spec("kill_shell", "Stop a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), false, false), + spec("todo_write", "Maintain a task list for the current work, shown live to the user. Send the complete list each time; exactly one item in_progress.", json!({"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}), true, false), + spec("ask_user", "Ask the user one or more questions and wait for the answers. Only for decisions that are genuinely the user's. Offer 2-4 options per question.", json!({"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"}},"required":["label"]}}},"required":["question"]}}},"required":["questions"]}), true, false), + spec("task", "Delegate a self-contained task to a subagent (fresh context). It returns only its final report.", json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"},"subagent_type":{"type":"string"}},"required":["description","prompt"]}), true, false), + spec("skill", "Load a skill (packaged instructions for a kind of task) by name and follow it.", json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), true, false), + spec("remember", "Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to .codecraft/memory.md – it is loaded into every future session here. Keep entries short; don't store secrets or temporary state.", json!({"type":"object","properties":{"note":{"type":"string"}},"required":["note"]}), false, true), + ] +} + +/// What tools may touch and use. +pub struct Ctx { + pub cwd: PathBuf, + pub cancel: CancellationToken, + pub read_files: Arc>>, + pub shells: Arc>>>, + pub server: Option, + /// Server skills (name -> description), fetched on demand. + pub server_skills: Vec, +} + +pub struct Output { + pub content: String, + pub is_error: bool, + pub images: Vec, + /// Unified diff of a file change (shown in the UI). + pub diff: Option, +} + +impl Output { + fn text(s: impl Into) -> Self { + Self { content: s.into(), is_error: false, images: vec![], diff: None } + } + pub fn error(s: impl Into) -> Self { + Self { content: s.into(), is_error: true, images: vec![], diff: None } + } +} + +fn arg<'a>(v: &'a Value, k: &str) -> Result<&'a str> { + v[k].as_str().ok_or_else(|| anyhow!("missing argument '{k}'")) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}\n… [truncated, {} more bytes]", &s[..end], s.len() - end) +} + +pub fn resolve(cwd: &Path, p: &str) -> PathBuf { + let p = p.trim(); + let raw = if Path::new(p).is_absolute() { + PathBuf::from(p) + } else if let Some(rest) = p.strip_prefix("~/") { + crate::conf::home().join(rest) + } else { + cwd.join(p) + }; + let mut out = PathBuf::new(); + for c in raw.components() { + match c { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other), + } + } + out +} + +pub fn unified_diff(path: &str, old: &str, new: &str) -> String { + similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(&format!("a/{path}"), &format!("b/{path}")).to_string() +} + +fn image_type(p: &Path) -> Option<&'static str> { + match p.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + } +} + +pub fn apply_edit(old: &str, input: &Value) -> Result { + let from = arg(input, "old_string")?; + let to = arg(input, "new_string")?; + if from.is_empty() { + bail!("old_string must not be empty (use write to create files)"); + } + let n = old.matches(from).count(); + if n == 0 { + bail!("old_string not found in the file – read it again and copy the exact text"); + } + if n > 1 && !input["replace_all"].as_bool().unwrap_or(false) { + bail!("old_string occurs {n} times – add more context or set replace_all"); + } + Ok(if input["replace_all"].as_bool().unwrap_or(false) { old.replace(from, to) } else { old.replacen(from, to, 1) }) +} + +/// The proposed change of a write/edit as a diff (for the permission dialog). +pub fn preview(name: &str, input: &Value, cwd: &Path) -> Option { + let path = resolve(cwd, input["path"].as_str()?); + let old = std::fs::read_to_string(&path).unwrap_or_default(); + let new = match name { + "write" => input["content"].as_str()?.to_string(), + "edit" => apply_edit(&old, input).ok()?, + _ => return None, + }; + Some(unified_diff(&rel(cwd, &path), &old, &new)) +} + +pub fn rel(cwd: &Path, p: &Path) -> String { + p.strip_prefix(cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string()) +} + +// --- background shells --------------------------------------------------------------- + +pub struct Shell { + #[allow(dead_code)] + pub command: String, + output: Mutex, + read_pos: Mutex, + child: Mutex>, + pub done: Mutex>, +} + +fn shell_cmd(cwd: &Path, command: &str) -> tokio::process::Command { + #[cfg(windows)] + let mut c = { + // Git Bash if available, else PowerShell. + let bash = std::env::var("CODECRAFT_SHELL").ok().or_else(|| which("bash")); + match bash { + Some(b) => { + let mut c = tokio::process::Command::new(b); + c.arg("-c").arg(format!("exec 2>&1\n{command}")); + c + } + None => { + let mut c = tokio::process::Command::new("powershell"); + c.args(["-NoProfile", "-Command", &format!("& {{ {command} }} 2>&1")]); + c + } + } + }; + #[cfg(not(windows))] + let mut c = { + let shell = std::env::var("CODECRAFT_SHELL").unwrap_or_else(|_| "bash".into()); + let mut c = tokio::process::Command::new(shell); + c.arg("-c").arg(format!("exec 2>&1\n{command}")); + c + }; + c.current_dir(cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat"); + c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).kill_on_drop(true); + #[cfg(unix)] + c.process_group(0); + c +} + +#[cfg(windows)] +fn which(name: &str) -> Option { + std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(format!("{name}.exe"))).find(|p| p.is_file()).map(|p| p.display().to_string())) +} + +fn kill_tree(child: &tokio::process::Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + unsafe extern "C" { + fn kill(pid: i32, sig: i32) -> i32; + } + // SAFETY: plain syscall; negative pid = process group. + unsafe { + kill(-(pid as i32), 9); + } + } + #[cfg(not(unix))] + let _ = child; +} + +async fn run_bash(ctx: &Ctx, input: &Value) -> Result { + let command = arg(input, "command")?; + let timeout = Duration::from_millis(input["timeout_ms"].as_u64().unwrap_or(120_000).min(600_000)); + let mut cmd = shell_cmd(&ctx.cwd, command); + if input["run_in_background"].as_bool().unwrap_or(false) { + let mut child = cmd.spawn()?; + let mut stdout = child.stdout.take().unwrap(); + let id = format!("sh{}", ctx.shells.lock().unwrap().len() + 1); + let shell = Arc::new(Shell { command: command.into(), output: Mutex::default(), read_pos: Mutex::new(0), child: Mutex::new(Some(child)), done: Mutex::new(None) }); + ctx.shells.lock().unwrap().insert(id.clone(), shell.clone()); + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + loop { + match stdout.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let mut out = shell.output.lock().unwrap(); + out.push_str(&String::from_utf8_lossy(&buf[..n])); + if out.len() > 2_000_000 { + let cut = out.len() - 1_000_000; + let mut c = cut; + while !out.is_char_boundary(c) { + c += 1; + } + out.drain(..c); + *shell.read_pos.lock().unwrap() = 0; + } + } + } + } + let child = shell.child.lock().unwrap().take(); + if let Some(mut c) = child { + let code = c.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1); + *shell.done.lock().unwrap() = Some(code); + } + }); + return Ok(Output::text(format!("Started in the background with shell id {id}. Read its output with bash_output, stop it with kill_shell."))); + } + let mut child = cmd.spawn()?; + let mut stdout = child.stdout.take().unwrap(); + let mut out = Vec::new(); + let read = async { + let _ = stdout.read_to_end(&mut out).await; + }; + let status = tokio::select! { + _ = read => child.wait().await.ok(), + _ = tokio::time::sleep(timeout) => { + kill_tree(&child); + let _ = child.kill().await; + return Ok(Output::error(format!("[command timed out after {}s and was killed]", timeout.as_secs()))); + } + _ = ctx.cancel.cancelled() => { + kill_tree(&child); + let _ = child.kill().await; + return Ok(Output::error("[command aborted by user]")); + } + }; + let code = status.and_then(|s| s.code()).unwrap_or(-1); + let mut text = truncate(String::from_utf8_lossy(&out).trim_end(), MAX_OUTPUT); + if text.is_empty() { + text = "(no output)".into(); + } + if code != 0 { + text.push_str(&format!("\n[exit code {code}]")); + } + Ok(Output { content: text, is_error: code != 0, images: vec![], diff: None }) +} + +// --- skills ------------------------------------------------------------------------------ + +/// Local skills of the project (.codecraft/skills, .claude/skills): name -> SKILL.md path. +pub fn project_skills(cwd: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + for base in [".claude", ".codecraft"] { + let dir = cwd.join(base).join("skills"); + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for e in entries.flatten() { + let p = e.path().join("SKILL.md"); + if p.is_file() { + out.insert(e.file_name().to_string_lossy().to_string(), p); + } + } + } + out +} + +pub fn builtin_skill_names() -> Vec<&'static str> { + BUILTIN.iter().filter_map(|(p, _)| p.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md"))).collect() +} + +fn skill_cache(name: &str) -> PathBuf { + crate::conf::data_dir().join("skills").join(name) +} + +async fn load_skill(ctx: &Ctx, name: &str) -> Result { + // Project skills win, then the server's, then the built-in ones. + if let Some(p) = project_skills(&ctx.cwd).get(name) { + let text = std::fs::read_to_string(p)?; + let dir = p.parent().unwrap_or(&ctx.cwd); + return Ok(Output::text(format!("{text}\n\n(Skill files are in {})", dir.display()))); + } + if let Some(server) = ctx.server.as_ref().filter(|_| ctx.server_skills.iter().any(|s| s == name)) { + let v = server.get(&format!("/cli/skill/{name}")).await?; + let dir = skill_cache(name); + for f in v["files"].as_array().into_iter().flatten() { + let rel = f["path"].as_str().unwrap_or(""); + if rel.contains("..") { + continue; + } + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap())?; + std::fs::write(&p, f["content"].as_str().unwrap_or(""))?; + } + return Ok(Output::text(format!("{}\n\n(Skill files are in {})", v["content"].as_str().unwrap_or(""), dir.display()))); + } + let prefix = format!("skills/{name}/"); + let files: Vec<&(&str, &str)> = BUILTIN.iter().filter(|(p, _)| p.starts_with(&prefix)).collect(); + if files.is_empty() { + bail!("unknown skill '{name}'"); + } + let dir = skill_cache(name); + let mut main = String::new(); + for (p, content) in files { + let rel = &p[prefix.len()..]; + if rel == "SKILL.md" { + main = content.to_string(); + } + let target = dir.join(rel); + std::fs::create_dir_all(target.parent().unwrap())?; + std::fs::write(&target, content)?; + } + Ok(Output::text(format!("{main}\n\n(Skill files are in {})", dir.display()))) +} + +// --- dispatcher ----------------------------------------------------------------------------- + +/// Run a local tool (ask_user and task are handled by the agent loop). +pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result { + match name { + "read" => { + let path = resolve(&ctx.cwd, arg(input, "path")?); + if path.is_dir() { + bail!("{} is a directory – use list", path.display()); + } + let bytes = tokio::fs::read(&path).await.map_err(|e| anyhow!("{}: {e}", path.display()))?; + ctx.read_files.lock().unwrap().insert(path.clone()); + if let Some(mt) = image_type(&path) { + return Ok(Output { + content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()), + is_error: false, + images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }], + diff: None, + }); + } + if bytes.iter().take(8000).any(|b| *b == 0) { + return Ok(Output::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len()))); + } + let text = String::from_utf8_lossy(&bytes); + let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize; + let limit = input["limit"].as_u64().unwrap_or(2000) as usize; + let mut out = String::new(); + let total = text.lines().count(); + for (i, l) in text.lines().enumerate().skip(offset - 1).take(limit) { + out.push_str(&format!("{:>6}\t{}\n", i + 1, if l.len() > 2000 { &l[..l.char_indices().nth(2000).map(|(i, _)| i).unwrap_or(l.len())] } else { l })); + } + if offset - 1 + limit < total { + out.push_str(&format!("… ({} more lines, use offset)\n", total - (offset - 1 + limit))); + } + Ok(Output::text(if out.is_empty() { "(file is empty)".into() } else { truncate(&out, MAX_OUTPUT * 2) })) + } + "write" => { + let path = resolve(&ctx.cwd, arg(input, "path")?); + let content = arg(input, "content")?; + let old = if path.exists() { + if !ctx.read_files.lock().unwrap().contains(&path) { + return Ok(Output::error("File exists but has not been read in this session. Read it first.")); + } + std::fs::read_to_string(&path).unwrap_or_default() + } else { + String::new() + }; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, content)?; + ctx.read_files.lock().unwrap().insert(path.clone()); + let diff = unified_diff(&rel(&ctx.cwd, &path), &old, content); + Ok(Output { content: format!("Wrote {} ({} lines)", rel(&ctx.cwd, &path), content.lines().count()), is_error: false, images: vec![], diff: Some(diff) }) + } + "edit" => { + let path = resolve(&ctx.cwd, arg(input, "path")?); + if !ctx.read_files.lock().unwrap().contains(&path) { + return Ok(Output::error("File has not been read in this session. Read it first.")); + } + let old = std::fs::read_to_string(&path)?; + let new = match apply_edit(&old, input) { + Ok(n) => n, + Err(e) => return Ok(Output::error(e.to_string())), + }; + std::fs::write(&path, &new)?; + let diff = unified_diff(&rel(&ctx.cwd, &path), &old, &new); + Ok(Output { content: format!("Edited {}", rel(&ctx.cwd, &path)), is_error: false, images: vec![], diff: Some(diff) }) + } + "list" => { + let path = resolve(&ctx.cwd, input["path"].as_str().unwrap_or(".")); + let mut entries: Vec = Vec::new(); + for e in ignore::WalkBuilder::new(&path).max_depth(Some(1)).hidden(false).build().flatten() { + if e.depth() == 0 || e.file_name() == ".git" { + continue; + } + let dir = e.file_type().is_some_and(|t| t.is_dir()); + entries.push(format!("{}{}", e.file_name().to_string_lossy(), if dir { "/" } else { "" })); + } + entries.sort(); + Ok(Output::text(if entries.is_empty() { "(empty)".into() } else { entries.join("\n") })) + } + "glob" => { + let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or(".")); + let matcher = globset::Glob::new(arg(input, "pattern")?)?.compile_matcher(); + let mut hits = Vec::new(); + for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() { + if e.file_type().is_some_and(|t| t.is_file()) { + let r = e.path().strip_prefix(&base).unwrap_or(e.path()); + if matcher.is_match(r) { + hits.push(rel(&ctx.cwd, e.path())); + if hits.len() >= 500 { + break; + } + } + } + } + Ok(Output::text(if hits.is_empty() { "No files found.".into() } else { hits.join("\n") })) + } + "grep" => { + let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or(".")); + let re = regex::RegexBuilder::new(arg(input, "pattern")?).case_insensitive(input["ignore_case"].as_bool().unwrap_or(false)).build()?; + let filter = input["glob"].as_str().map(|g| globset::Glob::new(g).map(|g| g.compile_matcher())).transpose()?; + let mut out = String::new(); + let mut n = 0; + 'files: for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() { + if !e.file_type().is_some_and(|t| t.is_file()) { + continue; + } + if let Some(f) = &filter { + if !f.is_match(e.file_name()) && !f.is_match(e.path().strip_prefix(&base).unwrap_or(e.path())) { + continue; + } + } + let Ok(text) = std::fs::read_to_string(e.path()) else { continue }; + for (i, line) in text.lines().enumerate() { + if re.is_match(line) { + out.push_str(&format!("{}:{}: {}\n", rel(&ctx.cwd, e.path()), i + 1, line.chars().take(300).collect::())); + n += 1; + if n >= 300 { + out.push_str("… (more matches – narrow the search)\n"); + break 'files; + } + } + } + } + Ok(Output::text(if out.is_empty() { "No matches.".into() } else { out })) + } + "bash" => run_bash(ctx, input).await, + "bash_output" => { + let id = arg(input, "shell_id")?; + let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?; + let out = shell.output.lock().unwrap(); + let mut pos = shell.read_pos.lock().unwrap(); + let new = out[(*pos).min(out.len())..].to_string(); + *pos = out.len(); + let state = match *shell.done.lock().unwrap() { + Some(c) => format!("[exited with code {c}]"), + None => "[running]".into(), + }; + Ok(Output::text(format!("{}\n{state}", truncate(&new, MAX_OUTPUT)))) + } + "kill_shell" => { + let id = arg(input, "shell_id")?; + let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?; + if let Some(c) = shell.child.lock().unwrap().as_ref() { + kill_tree(c); + } + if let Some(mut c) = shell.child.lock().unwrap().take() { + let _ = c.start_kill(); + } + Ok(Output::text(format!("Shell {id} stopped."))) + } + "todo_write" => { + let todos = input["todos"].as_array().cloned().unwrap_or_default(); + let open = todos.iter().filter(|t| t["status"] != "completed").count(); + Ok(Output::text(format!("Todo list updated ({open} open)."))) + } + "skill" => load_skill(ctx, arg(input, "name")?).await, + "remember" => { + let note = arg(input, "note")?.trim().replace('\n', " "); + let path = ctx.cwd.join(".codecraft").join("memory.md"); + std::fs::create_dir_all(path.parent().unwrap())?; + let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into()); + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&format!("- {note}\n")); + std::fs::write(&path, &text)?; + Ok(Output::text(format!("Saved to .codecraft/memory.md: {note}"))) + } + other => bail!("unknown tool '{other}'"), + } +} diff --git a/config.example.toml b/config.example.toml index 4936303..3633fd8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -187,6 +187,7 @@ web_fetch = "ask" web_search = "allow" diagnostics = "allow" openscad = "allow" # renders .scad files to STL/3MF + previews +remember = "allow" # project memory (.codecraft/memory.md) browser_snapshot = "allow" image_compare = "allow" generate_image = "ask" # costs money per image diff --git a/core/src/config.rs b/core/src/config.rs index 4107fb5..e77ed75 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -79,6 +79,9 @@ pub struct ModelConfig { /// Max. simultaneous requests to this model (None = unlimited). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrency: Option, + /// Model to use when this one's host is unreachable or its budget is used up ("provider/model"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback: Option, /// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens). #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub options: serde_json::Map, diff --git a/core/src/lib.rs b/core/src/lib.rs index bb15212..07a8016 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -3,5 +3,6 @@ pub mod config; pub mod permission; +pub mod prompts; pub mod provider; pub mod types; diff --git a/core/src/prompts.rs b/core/src/prompts.rs new file mode 100644 index 0000000..d545c16 --- /dev/null +++ b/core/src/prompts.rs @@ -0,0 +1,52 @@ +//! Built-in prompts (shared by the server and the terminal client). + +pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically."; + +pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools. + +# Working style +- Be direct and concise. Lead with what you did or found; no filler. +- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns. +- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output. +- Prefer editing existing files over creating new ones. Never create documentation files unless asked. +- Use todo_write to plan and track multi-step work; keep exactly one item in progress. +- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response. +- Use the task tool for broad searches or independent sub-tasks to keep your own context focused. +- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it. +- Never commit, push or perform destructive/irreversible actions unless the user asked for it. +- Reference code as path:line. +- Answer in the language the user writes in."#; + +pub const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project."; + +pub const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits. +Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#; + +pub const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a phase. + +1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly. +2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code. +3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width. +4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes. + +Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#; + +pub const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused)."; +pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine."; + +pub const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees."; +pub const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything."; + +/// Default review criteria per mode (used when the config gives none). +pub fn refine_criteria(agent: &str) -> &'static str { + match agent { + "plan" => "Is the plan complete and concrete (files, functions, order of steps)? Are risks, edge cases, migration and verification covered? Are open questions for the user stated? Is anything in it wrong for this codebase?", + "craft" => "Were the phases done properly (study, build, check)? Did the CHECK step really compare the result with the original (screenshots, outputs) and report remaining differences? Is the result original work (no copied code/assets)? What still differs visibly or behaviourally?", + _ => "Is the request fully implemented – every requirement, not just the main path? Is the code correct (logic, edge cases, error handling)? Were the changes verified (build, tests, running it) and do they pass? Any leftovers (TODOs, debug output, unused code), broken existing behaviour or missing docs/tests for the change?", + } +} + +/// The craft prompt for clean room or deep mode. +pub fn craft_prompt(clean_room: bool) -> String { + CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF }) +} diff --git a/docker-compose.yml b/docker-compose.yml index 7836625..ff65e0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,11 @@ # Rebuilding or recreating the container keeps all of it. services: codecraft: - build: . + build: + context: . + args: + # Shown in Settings and /api/health: GIT_COMMIT=$(git rev-parse --short HEAD) docker compose build + GIT_COMMIT: ${GIT_COMMIT:-} image: codecraft:latest container_name: codecraft restart: unless-stopped diff --git a/server/resources-cli/install.sh b/server/resources-cli/install.sh new file mode 100644 index 0000000..793a1a0 --- /dev/null +++ b/server/resources-cli/install.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Install the CodeCraft terminal client (codecraft) from __CODECRAFT_URL__ +# curl -fsSL __CODECRAFT_URL__/cli/install.sh | sh +# Options (environment): CODECRAFT_INSTALL_DIR (default ~/.local/bin) +set -eu + +SERVER="__CODECRAFT_URL__" +DIR="${CODECRAFT_INSTALL_DIR:-$HOME/.local/bin}" + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) +case "$arch" in + x86_64|amd64) arch=x86_64 ;; + aarch64|arm64) arch=aarch64 ;; + *) echo "codecraft: unsupported architecture $arch" >&2; exit 1 ;; +esac +case "$os" in + linux) ;; + *) echo "codecraft: no prebuilt binary for $os yet – build from source: cargo install --git codecraft-cli" >&2; exit 1 ;; +esac + +target="$os-$arch" +url="$SERVER/cli/download/$target" +mkdir -p "$DIR" +tmp="$DIR/.codecraft.download" +echo "Downloading codecraft ($target) from $SERVER …" +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$tmp" +elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$tmp" +else + echo "codecraft: needs curl or wget" >&2; exit 1 +fi +chmod +x "$tmp" +mv "$tmp" "$DIR/codecraft" +echo "Installed $DIR/codecraft ($("$DIR/codecraft" --version 2>/dev/null || echo codecraft))" + +case ":$PATH:" in + *":$DIR:"*) ;; + *) echo "Note: $DIR is not in your PATH – add this to your shell profile:" + echo " export PATH=\"$DIR:\$PATH\"" ;; +esac +echo +echo "Next steps:" +echo " codecraft login $SERVER # sign in with your CodeCraft account" +echo " codecraft # start in your project folder" diff --git a/server/src/api.rs b/server/src/api.rs index d2792a2..1b37f26 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -158,7 +158,53 @@ pub fn router(app: AppState) -> Router { let index = web_dir.join("index.html"); let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index)); - Router::new().nest("/api", api).fallback_service(static_files).with_state(app) + Router::new() + .nest("/api", api) + .route("/cli/install.sh", get(cli_install_script)) + .route("/cli/download/{target}", get(cli_download)) + .fallback_service(static_files) + .with_state(app) +} + +fn cli_dir() -> PathBuf { + std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli")) +} + +/// The server's public base URL (config, else from the request). +fn public_base(app: &App, headers: &axum::http::HeaderMap) -> String { + let cfg = app.config(); + if !cfg.auth.public_url.is_empty() { + return cfg.auth.public_url.trim_end_matches('/').to_string(); + } + let host = headers.get("x-forwarded-host").or(headers.get(axum::http::header::HOST)).and_then(|v| v.to_str().ok()).unwrap_or("localhost:8787"); + let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).unwrap_or("http"); + format!("{proto}://{host}") +} + +/// `curl -fsSL /cli/install.sh | sh` installs the terminal client. +async fn cli_install_script(State(app): State, headers: axum::http::HeaderMap) -> Response { + let base = public_base(&app, &headers); + let script = include_str!("../resources-cli/install.sh").replace("__CODECRAFT_URL__", &base); + ([(axum::http::header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], script).into_response() +} + +/// Static CLI binaries, e.g. /cli/download/linux-x86_64. +async fn cli_download(Path(target): Path) -> Response { + if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return (StatusCode::BAD_REQUEST, "invalid target").into_response(); + } + let path = cli_dir().join(format!("codecraft-{target}")); + match tokio::fs::read(&path).await { + Ok(bytes) => ( + [ + (axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"codecraft\"".to_string()), + ], + bytes, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, format!("no codecraft build for {target} on this server")).into_response(), + } } /// Load a session the user may at least read. @@ -1022,7 +1068,7 @@ async fn backup(Extension(user): Extension, State(app): State) - /// Liveness for monitoring (no auth needed – see auth::is_public). async fn health(State(app): State) -> Json { - Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()})) + Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()})) } // --- budget ------------------------------------------------------------------- diff --git a/server/src/app.rs b/server/src/app.rs index d2d8033..f0c8e07 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -77,6 +77,8 @@ pub struct App { pub push: crate::push::Push, /// Web terminals (PTYs). pub terminals: crate::terminal::Terminals, + /// Newer release found by the update check (admins see it). + pub update: crate::update::UpdateState, } /// What a user may do with a session. @@ -116,6 +118,7 @@ impl App { lsp: Default::default(), push: crate::push::Push::new(&data), terminals: Default::default(), + update: Default::default(), }); crate::auth::bootstrap(&app)?; app.clone().start_mcp(); diff --git a/server/src/auth.rs b/server/src/auth.rs index a0c3548..4a64a8a 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -253,7 +253,9 @@ pub async fn info(State(app): State>, headers: HeaderMap) -> Json Result<()> { push::spawn_listener(app.clone()); terminal::spawn_reaper(app.clone()); jobs::spawn_scheduler(app.clone()); + update::spawn_checker(app.clone()); let router = api::router(app); let listener = tokio::net::TcpListener::bind(&bind).await?; tracing::info!("listening on http://{bind}"); diff --git a/server/src/prompts.rs b/server/src/prompts.rs index 8d847db..fa1f5a6 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -35,56 +35,8 @@ pub struct Agent { pub path: Option, } -pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically."; +pub use codecraft_core::prompts::*; -pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools. - -# Working style -- Be direct and concise. Lead with what you did or found; no filler. -- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns. -- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output. -- Prefer editing existing files over creating new ones. Never create documentation files unless asked. -- Use todo_write to plan and track multi-step work; keep exactly one item in progress. -- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response. -- Use the task tool for broad searches or independent sub-tasks to keep your own context focused. -- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it. -- Never commit, push or perform destructive/irreversible actions unless the user asked for it. -- Reference code as path:line. -- Answer in the language the user writes in."#; - -const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project."; - -const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits. -Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#; - -const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a phase. - -1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly. -2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code. -3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width. -4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes. - -Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#; - -const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused)."; -const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine."; - -const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees."; -const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything."; - -/// Default review criteria per mode (used when the config gives none). -pub fn refine_criteria(agent: &str) -> &'static str { - match agent { - "plan" => "Is the plan complete and concrete (files, functions, order of steps)? Are risks, edge cases, migration and verification covered? Are open questions for the user stated? Is anything in it wrong for this codebase?", - "craft" => "Were the phases done properly (study, build, check)? Did the CHECK step really compare the result with the original (screenshots, outputs) and report remaining differences? Is the result original work (no copied code/assets)? What still differs visibly or behaviourally?", - _ => "Is the request fully implemented – every requirement, not just the main path? Is the code correct (logic, edge cases, error handling)? Were the changes verified (build, tests, running it) and do they pass? Any leftovers (TODOs, debug output, unused code), broken existing behaviour or missing docs/tests for the change?", - } -} - -/// The craft prompt for clean room or deep mode. -pub fn craft_prompt(clean_room: bool) -> String { - CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF }) -} pub fn builtin_agents(clean_room: bool) -> Vec { let craft = craft_prompt(clean_room); @@ -270,6 +222,17 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: & s.push_str(&format!("- {}: {}\n", sk.name, sk.description)); } } + if let Some(u) = owner { + let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default(); + if !memory.trim().is_empty() { + s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); + } + } + match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { + Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::())), + Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"), + Err(_) => {} + } let files = instruction_files(app, root, cwd); if !files.is_empty() { s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n"); diff --git a/server/src/runner.rs b/server/src/runner.rs index 6b1889d..e14728c 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -300,7 +300,18 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo reasoning: true, model: model.clone(), }; - let step = stream_step(&app, &session, &agent.name, &model_spec, provider, req, &cancel).await?; + let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await { + Ok(step) => step, + // Host down or budget used up: switch to the model's fallback for this step. + Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => { + let fb = model.fallback.clone().unwrap_or_default(); + let (fb_provider, fb_model) = app.registry().resolve(&fb)?; + app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) }); + let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req }; + stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await? + } + Err(e) => return Err(e), + }; final_text = step .message .parts @@ -747,9 +758,20 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 { (chars as f64 / 3.8) as u64 + images * 1000 } +/// Errors after which the fallback model takes over: the host is unreachable +/// (connection, timeout, gateway errors) or the provider's budget is used up. +fn needs_fallback(e: &anyhow::Error) -> bool { + let s = format!("{e:#}").to_lowercase(); + ["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k)) +} + +fn short_error(e: &anyhow::Error) -> String { + format!("{e:#}").lines().next().unwrap_or("").chars().take(120).collect() +} + fn is_retryable(e: &anyhow::Error) -> bool { - let s = format!("{e:#}"); - s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out") + let s = format!("{e:#}").to_lowercase(); + ["http 429", "http 5", "overloaded", "connect", "timed out", "timeout"].iter().any(|k| s.contains(k)) } /// One model request with streaming to the UI, usage recording and retries. diff --git a/server/src/tools/agent.rs b/server/src/tools/agent.rs index ad08a17..34f3c96 100644 --- a/server/src/tools/agent.rs +++ b/server/src/tools/agent.rs @@ -97,6 +97,45 @@ impl Tool for CraftPhase { // --------------------------------------------------------------------------- +/// Project memory: lasting facts about the project in .codecraft/memory.md. +pub struct Remember; + +#[async_trait] +impl Tool for Remember { + fn name(&self) -> &str { + "remember" + } + fn description(&self) -> String { + "Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to \ + .codecraft/memory.md – it is loaded into every future session in this project. Keep entries short; \ + never store secrets or temporary state." + .into() + } + fn schema(&self) -> Value { + json!({"type": "object", "properties": {"note": {"type": "string"}}, "required": ["note"]}) + } + fn is_edit(&self) -> bool { + true + } + fn title(&self, input: &Value) -> String { + input["note"].as_str().unwrap_or("").chars().take(80).collect() + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let note = arg_str(&input, "note")?.trim().replace('\n', " "); + let path = ctx.resolve(".codecraft/memory.md")?; + std::fs::create_dir_all(path.parent().unwrap())?; + let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into()); + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&format!("- {note}\n")); + std::fs::write(&path, &text)?; + Ok(ToolOutput::text(format!("Saved to .codecraft/memory.md: {note}")).with_meta(json!({"memory": note}))) + } +} + +// --------------------------------------------------------------------------- + /// Switch the session's working directory (only on the user's request). pub struct ChangeDirectory; diff --git a/server/src/tools/mod.rs b/server/src/tools/mod.rs index 9a0b28f..9ac68c8 100644 --- a/server/src/tools/mod.rs +++ b/server/src/tools/mod.rs @@ -164,6 +164,7 @@ pub fn builtin_tools() -> Vec> { Arc::new(web::WebSearch), Arc::new(agent::CraftPhase), Arc::new(agent::ChangeDirectory), + Arc::new(agent::Remember), Arc::new(cad::OpenScad), Arc::new(crate::jobs::Schedule), Arc::new(crate::lsp::Diagnostics), diff --git a/server/src/update.rs b/server/src/update.rs new file mode 100644 index 0000000..99296c3 --- /dev/null +++ b/server/src/update.rs @@ -0,0 +1,57 @@ +//! Version information and a daily check for new releases. + +use crate::app::App; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Git commit the image was built from (Docker build arg GIT_COMMIT). +pub fn commit() -> &'static str { + option_env!("CODECRAFT_COMMIT").unwrap_or("") +} + +#[derive(Default)] +pub struct UpdateState { + pub latest: Mutex>, +} + +fn newer(a: &str, b: &str) -> bool { + let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::().unwrap_or(0)).collect::>(); + p(a) > p(b) +} + +async fn check(app: &App) -> Option { + let url = app.config().server.update_url.clone(); + if url.is_empty() { + return None; + } + let v: Value = crate::provider::http_client().get(&url).timeout(Duration::from_secs(20)).send().await.ok()?.json().await.ok()?; + let tag = v["tag_name"].as_str()?.to_string(); + if !newer(&tag, VERSION) { + return None; + } + Some(json!({"version": tag.trim_start_matches('v'), "url": v["html_url"], "notes": v["body"].as_str().unwrap_or("").chars().take(4000).collect::(), "published_at": v["published_at"]})) +} + +pub fn spawn_checker(app: Arc) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + loop { + let found = check(&app).await; + *app.update.latest.lock().unwrap() = found; + tokio::time::sleep(Duration::from_secs(24 * 3600)).await; + } + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn version_compare() { + assert!(super::newer("v0.2.0", "0.1.9")); + assert!(super::newer("1.0.0", "0.9.9")); + assert!(!super::newer("v0.1.0", "0.1.0")); + } +} diff --git a/web/public/icon-maskable-512.png b/web/public/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b0450da5dc0ca790b99f464cec44be8c03e09655 GIT binary patch literal 16673 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 literal 0 HcmV?d00001 diff --git a/web/public/screenshots/narrow.png b/web/public/screenshots/narrow.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca70ff9b69340443fa6aa3174520c4e2c6ce292 GIT binary patch literal 131998 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 z%0D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 z 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 Xp1*704G@U`GdmKaqpw|uvW@sZcl}GZ literal 0 HcmV?d00001 diff --git a/web/public/screenshots/wide.png b/web/public/screenshots/wide.png new file mode 100644 index 0000000000000000000000000000000000000000..de38c02d22012435d23535dda8c9b3a915d57a5a GIT binary patch literal 103284 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 HgU|l~gf?$l literal 0 HcmV?d00001 diff --git a/web/src/App.tsx b/web/src/App.tsx index 72733f9..8c96764 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { Jobs } from "./components/Jobs"; import { Spinner, useTitleActivity } from "./components/Activity"; import { DialogHost } from "./dialogs"; import { startPrefsSync } from "./prefsSync"; +import { WhatsNew } from "./components/WhatsNew"; // The file manager (editor, git) is loaded on demand to keep the chat small. const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files }))); @@ -31,7 +32,7 @@ export function App() { const refresh = async () => { try { const i = await get("/info"); - setState(() => ({ me: i.user })); + setState(() => ({ me: i.user, update: i.update ?? null })); if (i.user) { await loadSessions(); void startPrefsSync(); @@ -70,6 +71,7 @@ export function App() { <> + {info?.user && } ); diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 7f63a5c..b350aa6 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -5,6 +5,7 @@ import { useFetch, useModels } from "../hooks"; import { getLang, languages, setLang, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; +import { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install"; import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push"; import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme"; import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs"; @@ -109,12 +110,36 @@ function InterfaceSettings() {

{t("settings.reasoningHint")}
+ + + +
{t("settings.notificationsHint")}
); } +function InstallControl() { + const t = useT(); + const prompt = useSyncExternalStore(onInstallChange, canPrompt); + if (isInstalled()) return {t("install.installed")}; + if (prompt) + return ( +
+ +
+ ); + return
{t(`install.hint.${platformHint()}` as Key)}
; +} + +function VersionInfo() { + const info = useFetch<{ version: string; commit?: string }>("/info").data; + return
{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}
; +} + /** Web Push on this device: enable/disable and a test notification. */ function PushControl() { const t = useT(); @@ -313,6 +338,7 @@ interface ModelRow { cache_read_price?: number; cache_write_price?: number; max_concurrency?: number; + fallback?: string; options?: Record; } @@ -479,6 +505,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () {t("settings.priceCacheRead")} {t("settings.priceCacheWrite")} {t("settings.slots")} + {t("settings.fallback")} @@ -516,6 +543,9 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () setModel(i, { max_concurrency: num(e.target.value) })} /> + + setModel(i, { fallback: v || undefined })} emptyLabel="–" /> + + + + + ); +} diff --git a/web/src/components/settings/Account.tsx b/web/src/components/settings/Account.tsx index a73fe95..b972a35 100644 --- a/web/src/components/settings/Account.tsx +++ b/web/src/components/settings/Account.tsx @@ -4,6 +4,8 @@ import { useT } from "../../i18n"; import { useStore } from "../../store"; import { GitAccount } from "./GitAccount"; import { ApiTokens } from "./ApiTokens"; +import { CliInstall } from "./CliInstall"; +import { Memory } from "./Memory"; export function Account() { const t = useT(); @@ -46,7 +48,9 @@ export function Account() {
{t("account.hint")}
+ + ); diff --git a/web/src/components/settings/CliInstall.tsx b/web/src/components/settings/CliInstall.tsx new file mode 100644 index 0000000..65cd3ef --- /dev/null +++ b/web/src/components/settings/CliInstall.tsx @@ -0,0 +1,44 @@ +import { useT } from "../../i18n"; +import { toast } from "../../dialogs"; +import { Icon } from "../Icon"; + +/** How to install and use the terminal client. */ +export function CliInstall() { + const t = useT(); + const cmd = `curl -fsSL ${location.origin}/cli/install.sh | sh`; + const copy = (s: string) => navigator.clipboard.writeText(s).then(() => toast(t("tokens.copied"), "success")); + return ( +
+

+ {t("cli.title")} +

+
+ {t("cli.intro")} +
+
    +
  1. + {t("cli.step1")} +
    + {cmd} + +
    +
  2. +
  3. + {t("cli.step2")} +
    + {`codecraft login ${location.origin}`} + +
    +
  4. +
  5. {t("cli.step3")}
  6. +
+
+ {t("cli.downloads")} Linux x86_64 · Linux arm64 +
+
+ ); +} diff --git a/web/src/components/settings/Memory.tsx b/web/src/components/settings/Memory.tsx new file mode 100644 index 0000000..9b236a1 --- /dev/null +++ b/web/src/components/settings/Memory.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { get, put } from "../../api"; +import { useT } from "../../i18n"; +import { toast } from "../../dialogs"; +import { Icon } from "../Icon"; + +/** Personal memory: preferences that apply in every project (web and terminal). */ +export function Memory() { + const t = useT(); + const [text, setText] = useState(); + useEffect(() => { + get<{ memory: string }>("/account/memory") + .then((r) => setText(r.memory ?? "")) + .catch((e) => toast(e.message)); + }, []); + if (text === undefined) return null; + return ( +
+

+ {t("memory.title")} +

+
+ {t("memory.hint")} +
+