diff --git a/.gitignore b/.gitignore index b326c650..161148cf 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ apps/ .claude/scheduled_tasks.lock .claude/worktrees/ .worktrees/ +.ace-tool/ diff --git a/Cargo.lock b/Cargo.lock index 68ddfd22..921879b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -45,7 +45,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -451,7 +453,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -460,6 +471,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -481,6 +498,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -503,6 +529,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -519,6 +551,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.24.0" @@ -629,7 +667,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -745,6 +783,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -776,6 +828,15 @@ dependencies = [ "unicode-segmentation", ] +[[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.9.4" @@ -811,6 +872,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -861,6 +931,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -886,6 +974,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "0.1.26" @@ -930,6 +1027,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbus" version = "0.9.11" @@ -955,7 +1058,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "sha2", + "sha2 0.10.9", "zeroize", ] @@ -1026,7 +1129,7 @@ dependencies = [ "deepseek-secrets", "dirs", "serde", - "toml", + "toml 0.9.11+spec-1.1.0", "tracing", ] @@ -1140,7 +1243,7 @@ dependencies = [ "clap", "clap_complete", "colored", - "crossterm", + "crossterm 0.28.1", "deepseek-secrets", "deepseek-tools", "dirs", @@ -1155,13 +1258,15 @@ dependencies = [ "pdf-extract", "portable-pty", "pretty_assertions", - "ratatui", + "ratatui 0.29.0", "regex", "reqwest", "rustyline 15.0.0", + "schemars", + "schemaui", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "shellexpand", "shlex", "starlark", @@ -1171,7 +1276,7 @@ dependencies = [ "tiny_http", "tokio", "tokio-util", - "toml", + "toml 0.9.11+spec-1.1.0", "tower-http", "tracing", "unicode-segmentation", @@ -1236,7 +1341,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" dependencies = [ - "derive_more-impl", + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", ] [[package]] @@ -1245,13 +1359,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ - "convert_case", + "convert_case 0.6.0", "proc-macro2", "quote", "syn 2.0.114", "unicode-xid", ] +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "diff" version = "0.1.13" @@ -1264,11 +1391,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1342,6 +1479,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1380,12 +1526,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "ena" version = "0.14.3" @@ -1516,6 +1677,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax 0.8.8", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1606,6 +1778,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1618,6 +1801,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1642,6 +1831,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1867,7 +2066,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1875,6 +2074,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1918,7 +2122,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1975,6 +2179,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2216,6 +2429,25 @@ dependencies = [ "tiff", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indenter" version = "0.3.4" @@ -2230,6 +2462,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2346,6 +2580,15 @@ dependencies = [ "either", ] +[[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.17" @@ -2394,6 +2637,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc59d2432e047d6090ba1d83c782d0128bd6203857978218f5614dbd3287281f" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax 0.8.8", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.17", +] + [[package]] name = "keyring" version = "3.6.3" @@ -2418,7 +2699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" dependencies = [ "ascii-canvas", - "bit-set", + "bit-set 0.5.3", "diff", "ena", "is-terminal", @@ -2485,6 +2766,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -2513,6 +2803,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2578,6 +2874,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2616,7 +2921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2643,6 +2948,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -2811,6 +3122,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -2876,6 +3193,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.3" @@ -3027,6 +3353,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -3424,16 +3756,79 @@ checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.10.0", "cassowary", - "compact_str", - "crossterm", + "compact_str 0.8.1", + "crossterm 0.28.1", "indoc", "instability", "itertools 0.13.0", - "lru", + "lru 0.12.5", "paste", - "strum", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate 1.1.0", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str 0.9.0", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru 0.16.4", + "strum 0.27.2", + "thiserror 2.0.17", + "unicode-segmentation", + "unicode-truncate 2.0.1", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", "unicode-segmentation", - "unicode-truncate", "unicode-width 0.2.0", ] @@ -3497,6 +3892,23 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "referencing" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb674900ca31acd75c4aaf63f48e43e719631c0539ea5a9e64163d1296bcb730" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.2" @@ -3511,9 +3923,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3612,6 +4024,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3829,6 +4250,54 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + +[[package]] +name = "schemaui" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3337ab3373698fc2f2460beb48476e5be13a220e14ed542cf910cc2e10f36177" +dependencies = [ + "anyhow", + "axum", + "crossterm 0.29.0", + "include_dir", + "indexmap", + "jsonschema", + "percent-encoding", + "ratatui 0.30.0", + "regex", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "toml 1.0.6+spec-1.1.0", + "unicode-width 0.2.0", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3850,7 +4319,7 @@ dependencies = [ "once_cell", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -3890,6 +4359,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -3920,12 +4395,24 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -4025,8 +4512,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -4036,8 +4523,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -4154,7 +4652,7 @@ dependencies = [ "cmp_any", "debugserver-types", "derivative", - "derive_more", + "derive_more 1.0.0", "display_container", "dupe", "either", @@ -4218,7 +4716,7 @@ dependencies = [ "annotate-snippets", "anyhow", "derivative", - "derive_more", + "derive_more 1.0.0", "dupe", "lalrpop", "lalrpop-util", @@ -4268,7 +4766,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -4284,6 +4791,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4447,7 +4966,9 @@ checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4586,10 +5107,25 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.14", +] + +[[package]] +name = "toml" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", ] [[package]] @@ -4601,6 +5137,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" @@ -4608,18 +5153,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", - "winnow", + "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.2", ] [[package]] @@ -4723,9 +5268,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -4744,6 +5289,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -4776,6 +5327,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -4842,6 +5404,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4854,6 +5426,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -5423,6 +6001,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + [[package]] name = "winreg" version = "0.10.1" diff --git a/END_OF_NIGHT_REPORT.md b/END_OF_NIGHT_REPORT.md deleted file mode 100644 index 05c093b0..00000000 --- a/END_OF_NIGHT_REPORT.md +++ /dev/null @@ -1,109 +0,0 @@ -# End-of-Night Report — v0.8.5 Backlog Sprint - -**Date:** Overnight session -**Branch:** feat/v0.8.5 (HEAD a8be33b3) -**Baseline:** Clean git status, clippy passes, 1755/1756 tests pass (1 pre-existing env-dependent config failure) - ---- - -## Summary - -Three stacked incremental features landed tonight. Each was scoped as a self-contained commit so they can be cherry-picked or reverted independently. - ---- - -## Completed - -### #361 — `ApiProvider::DeepseekCN` for China Endpoint ✅ - -**Commit:** `e5f56dee` - -- Added `ApiProvider::DeepseekCN` variant to the core enum -- Default base URL: `https://api.deepseeki.com` -- Auto-detect: if `base_url` contains `api.deepseeki.com`, treat as DeepseekCN -- Locale auto-suggest: if no provider is configured and system locale is `zh-*`, default to DeepseekCN at startup -- All match arms updated across config.rs, client.rs, provider_picker.rs, main.rs, ui.rs, and command_palette.rs -- Provider picker now shows 7 entries (DeepseekCN inserted after Deepseek) -- Provider picker test updated for the new entry (up → up → enter now targets Deepseek instead of up → enter) - -### #355 — Atomic File Writes for ~/.deepseek/ ✅ - -**Commit:** `5bd63c77` - -- Added `write_atomic(path, contents)` helper in `utils.rs` using `NamedTempFile` + `fsync` + `persist` (atomic rename) -- Added `open_append(path)` and `flush_and_sync(writer)` for append-only logs -- Converted all non-append write sites: - - `session_manager.rs`: `save_session`, `save_checkpoint`, `save_offline_queue_state` - - `workspace_trust.rs`: `write_trust_file_at` - - `task_manager.rs`: `write_json_atomic` → delegates to `write_atomic` - - `runtime_threads.rs`: `write_json_atomic` → delegates to `write_atomic`, `append_event` now calls `sync_all` - - `mcp.rs`: `save_config`, `init_config`, `save_legacy` - - `audit.rs`: buffered append with `flush_and_sync` after each event - - `main.rs`: `save_mcp_config` → `write_atomic` -- Added 4 unit tests covering writing, replacing, temp-file cleanup, and append - -### #346 — Panic Safety Foundations ✅ (partial) - -**Commit:** `a8be33b3` - -- Added `spawn_supervised(name, location, future)` to `utils.rs`: - - Wraps future in `AssertUnwindSafe` + `catch_unwind` (via `futures_util::FutureExt`) - - On panic: logs via `tracing::error!`, writes crash dump to `~/.deepseek/crashes/-.log` - - Returns `JoinHandle<()>` — panic is caught internally so parent stays alive -- Added `write_panic_dump()` helper for crash dump writing -- Added process-level panic hook in `main.rs` that writes crash dump before invoking original hook -- Converted `persistence_actor::spawn_persistence_actor` as the first `spawn_supervised` caller - -**Remaining:** ~34 `tokio::spawn` sites still unconverted (low risk — tokio isolates panicked tasks from the process; this gap is just crash dump coverage + structured logging). - ---- - -## Not Completed - -### Phase 2 Issues (all untouched) - -| Issue | Scope | Reason deferred | -|-------|-------|----------------| -| #338 | `/config ` wiring | Not started — well-scoped, could be done next | -| #342 | Paste in provider picker | Not started — needs UI event routing | -| #343 | `/logout` stale key | Not started — needs client rebuild | -| #345 | Submit-disposition UX | Not started — larger UX change | -| #286/#352 | NVIDIA NIM / China endpoint CI | Not started — integration-test scope | - ---- - -## Key Decisions & Design Notes - -### #361 — DeepseekCN shares API key slot with Deepseek -Both variants use the same `DEEPSEEK_API_KEY` env var and keyring slot (`deepseek`). The distinction is purely the base URL (`api.deepseek.com` vs `api.deepseeki.com`). The config stores a `[providers.deepseek_cn]` block for provider-scoped overrides but the credential is shared. - -### #355 — Task artifact writes excluded from atomic conversion -`task_manager.rs:1346` writes task artifacts to `~/.deepseek//artifacts//`. These are secondary outputs — losing one to a crash is inconvenient but not dangerous. Left as bare `fs::write` to avoid unnecessary `NamedTempFile` churn. - -### #346 — Only 1 of ~15 production `tokio::spawn` sites converted -The `spawn_supervised` wrapper exists and is proved by `persistence_actor`. Converting every spawn site is mechanically safe but requires per-site analysis (some spawns need `JoinHandle` for `.await` on the result). The remaining 14 production sites are straightforward fire-and-forget patterns that don't need return values. - ---- - -## Pre-existing Test Failures - -Two config tests fail in CI due to environment-dependent `dirs::home_dir()` behavior: -- `config::tests::test_load_falls_back_to_home_config_when_env_path_missing` -- `config::tests::test_load_uses_tilde_expanded_deepseek_config_path` - -These are sandbox issues where `HOME` env resolution differs from `dirs::home_dir()`. Not caused by these changes. - ---- - -## Coverage Summary - -| Metric | Value | -|--------|-------| -| New commits | 3 | -| Issues fully addressed | 2 (#355, #361) | -| Issues partially addressed | 1 (#346) | -| Files changed | ~18 | -| Lines added | ~360 | -| New tests | 4 (atomic writes) | -| Clippy | Clean | -| Test suite | 1755/1756 pass (1 pre-existing env failure) | diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f2b52535..0c91d358 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -7,6 +7,13 @@ repository.workspace = true description = "Terminal UI for DeepSeek" default-run = "deepseek-tui" +[features] +default = ["tui", "json", "toml"] +tui = ["dep:schemaui", "schemaui/tui", "json", "toml"] +web = ["dep:schemaui", "schemaui/web", "json", "toml"] +json = ["schemaui/json"] +toml = ["schemaui/toml"] + [[bin]] name = "deepseek-tui" path = "src/main.rs" @@ -16,6 +23,7 @@ anyhow = "1.0.100" arboard = "3.4" deepseek-secrets = { path = "../secrets", version = "0.8.5" } deepseek-tools = { path = "../tools", version = "0.8.5" } +schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" base64 = "0.22.1" @@ -33,7 +41,8 @@ regex = "1.11" reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] } rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" +serde_json = { version = "1.0.149", features = ["preserve_order"] } +schemars = { version = "1.2.1", features = ["derive", "preserve_order"] } shellexpand = "3" toml = "0.9.7" tokio = { version = "1.49.0", features = ["full"] } diff --git a/crates/tui/src/commands/attachment.rs b/crates/tui/src/commands/attachment.rs index 82abe0d4..60d9a6ac 100644 --- a/crates/tui/src/commands/attachment.rs +++ b/crates/tui/src/commands/attachment.rs @@ -72,6 +72,8 @@ mod tests { TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: false, use_mouse_capture: false, diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index b6936021..45b72e29 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -4,30 +4,59 @@ use std::path::{Path, PathBuf}; use super::CommandResult; use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name}; +use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::localization::resolve_locale; use crate::settings::Settings; use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus}; use crate::tui::approval::ApprovalMode; -/// Open the interactive config editor modal, or handle `/config `. -pub fn show_config(_app: &mut App) -> CommandResult { - CommandResult::action(AppAction::OpenConfigView) +/// Open the interactive config editor. +/// +/// Bare `/config` opens the legacy Native modal (the `OpenConfigView` action), +/// preserving the v0.8.4 behaviour. `/config tui` opens the new +/// schemaui-driven TUI editor; `/config web` launches the web editor (only +/// available in builds compiled with the `web` feature). +pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult { + let mode = match parse_mode(arg) { + Ok(mode) => mode, + Err(err) => return CommandResult::error(err), + }; + if mode == ConfigUiMode::Web && !cfg!(feature = "web") { + return CommandResult::error( + "This build does not include the web config UI. Rebuild with the `web` feature.", + ); + } + let action = match mode { + ConfigUiMode::Native => AppAction::OpenConfigView, + ConfigUiMode::Tui | ConfigUiMode::Web => AppAction::OpenConfigEditor(mode), + }; + CommandResult::action(action) } /// Dispatch `/config` with optional args. /// -/// - `/config` (no args) — opens the interactive editor modal. +/// - `/config` (no args) — opens the schemaui-driven TUI editor. +/// - `/config tui` / `/config web` / `/config native` — open a specific +/// editor mode (web requires the `web` build feature). /// - `/config ` — shows the current value of a setting. /// - `/config ` — sets a runtime value (session only, no --save). pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult { let raw = arg.map(str::trim).unwrap_or(""); if raw.is_empty() { - return show_config(app); + return show_config(app, None); } let parts: Vec<&str> = raw.splitn(2, ' ').collect(); if parts.len() == 1 { + // Single arg: editor-mode shortcut OR show-value request. + let token = parts[0]; + if matches!( + token.to_ascii_lowercase().as_str(), + "tui" | "web" | "native" + ) { + return show_config(app, Some(token)); + } // `/config ` — show current value - show_single_setting(app, parts[0]) + show_single_setting(app, token) } else { // `/config ` — set value set_config_value(app, parts[0], parts[1], false) @@ -159,7 +188,7 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu Ok(path) } -fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { +pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { use anyhow::Context; use std::fs; @@ -370,6 +399,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { message: Some(message), action, + is_error: false, } } @@ -648,6 +678,8 @@ mod tests { let options = TuiOptions { model: "test-model".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, @@ -689,10 +721,18 @@ mod tests { } #[test] - fn test_show_config_opens_config_editor() { + fn test_show_config_defaults_to_native() { let mut app = create_test_app(); app.total_tokens = 1234; - let result = show_config(&mut app); + let result = show_config(&mut app, None); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); + } + + #[test] + fn test_show_config_native_opens_legacy_editor() { + let mut app = create_test_app(); + let result = show_config(&mut app, Some("native")); assert!(result.message.is_none()); assert!(matches!(result.action, Some(AppAction::OpenConfigView))); } diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 4c5ea35f..684d0413 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -266,6 +266,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("/tmp/test-workspace"), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/cycle.rs b/crates/tui/src/commands/cycle.rs index c965d8eb..c2daed63 100644 --- a/crates/tui/src/commands/cycle.rs +++ b/crates/tui/src/commands/cycle.rs @@ -151,6 +151,8 @@ mod tests { TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 8d4b9d0f..f95c21c7 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -267,6 +267,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("/tmp/test-workspace"), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 13985e20..e698f9e0 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -163,6 +163,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/jobs.rs b/crates/tui/src/commands/jobs.rs index b213b88e..5edb38c9 100644 --- a/crates/tui/src/commands/jobs.rs +++ b/crates/tui/src/commands/jobs.rs @@ -72,6 +72,8 @@ mod tests { TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: false, use_mouse_capture: false, diff --git a/crates/tui/src/commands/mcp.rs b/crates/tui/src/commands/mcp.rs index 71c0f08c..75ecdde4 100644 --- a/crates/tui/src/commands/mcp.rs +++ b/crates/tui/src/commands/mcp.rs @@ -78,6 +78,8 @@ mod tests { TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: false, use_mouse_capture: false, diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 08dcd428..9a341bbf 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -30,6 +30,8 @@ pub struct CommandResult { pub message: Option, /// Optional action for the app to take pub action: Option, + /// Whether the command failed. + pub is_error: bool, } impl CommandResult { @@ -38,6 +40,7 @@ impl CommandResult { Self { message: None, action: None, + is_error: false, } } @@ -46,6 +49,7 @@ impl CommandResult { Self { message: Some(msg.into()), action: None, + is_error: false, } } @@ -54,6 +58,7 @@ impl CommandResult { Self { message: None, action: Some(action), + is_error: false, } } @@ -63,6 +68,7 @@ impl CommandResult { Self { message: Some(msg.into()), action: Some(action), + is_error: false, } } @@ -71,6 +77,7 @@ impl CommandResult { Self { message: Some(format!("Error: {}", msg.into())), action: None, + is_error: true, } } } @@ -501,6 +508,11 @@ pub fn persist_status_items( config::persist_status_items(items) } +/// Persist a root-level string key in `config.toml`. +pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { + config::persist_root_string_key(key, value) +} + /// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from /// Zhang et al. (arXiv:2512.24601). /// @@ -665,6 +677,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, @@ -792,6 +806,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: workspace.clone(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/note.rs b/crates/tui/src/commands/note.rs index 85d8a089..6a356625 100644 --- a/crates/tui/src/commands/note.rs +++ b/crates/tui/src/commands/note.rs @@ -60,6 +60,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index f079eff9..8b5a7a89 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -72,6 +72,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index 110a6170..09d34f59 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -139,6 +139,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs index 2395dd43..f1d39b5a 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/restore.rs @@ -112,6 +112,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace, + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/review.rs b/crates/tui/src/commands/review.rs index 8c0a07de..2dedae44 100644 --- a/crates/tui/src/commands/review.rs +++ b/crates/tui/src/commands/review.rs @@ -73,6 +73,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 89e61916..1035a11c 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -223,6 +223,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index 4ae9369b..120e2b01 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -365,6 +365,8 @@ mod tests { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: true, use_mouse_capture: false, diff --git a/crates/tui/src/commands/task.rs b/crates/tui/src/commands/task.rs index dcfcbf91..9041f33e 100644 --- a/crates/tui/src/commands/task.rs +++ b/crates/tui/src/commands/task.rs @@ -52,6 +52,8 @@ mod tests { TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), + config_path: None, + config_profile: None, allow_shell: false, use_alt_screen: false, use_mouse_capture: false, diff --git a/crates/tui/src/composer_history.rs b/crates/tui/src/composer_history.rs index 21679a70..df12ea46 100644 --- a/crates/tui/src/composer_history.rs +++ b/crates/tui/src/composer_history.rs @@ -12,7 +12,7 @@ use std::fs; use std::io::{BufRead, BufReader}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Hard cap on persisted history. Keeps the file small (typical entries /// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load @@ -21,7 +21,7 @@ pub const MAX_HISTORY_ENTRIES: usize = 1000; const HISTORY_FILE_NAME: &str = "composer_history.txt"; -fn history_path() -> Option { +fn default_history_path() -> Option { dirs::home_dir().map(|home| home.join(".deepseek").join(HISTORY_FILE_NAME)) } @@ -29,10 +29,14 @@ fn history_path() -> Option { /// file doesn't exist or can't be parsed — this is best-effort. #[must_use] pub fn load_history() -> Vec { - let Some(path) = history_path() else { + let Some(path) = default_history_path() else { return Vec::new(); }; - let Ok(file) = fs::File::open(&path) else { + load_history_from(&path) +} + +fn load_history_from(path: &Path) -> Vec { + let Ok(file) = fs::File::open(path) else { return Vec::new(); }; BufReader::new(file) @@ -49,13 +53,17 @@ pub fn load_history() -> Vec { /// Best-effort — failures are logged via `tracing` but not propagated /// because composer history is a UX nicety, not a correctness concern. pub fn append_history(entry: &str) { + let Some(path) = default_history_path() else { + return; + }; + append_history_to(&path, entry); +} + +fn append_history_to(path: &Path, entry: &str) { let trimmed = entry.trim(); if trimmed.is_empty() || trimmed.starts_with('/') { return; } - let Some(path) = history_path() else { - return; - }; if let Some(parent) = path.parent() && let Err(err) = fs::create_dir_all(parent) { @@ -68,7 +76,7 @@ pub fn append_history(entry: &str) { // Read existing entries, append the new one, prune from the front // until under the cap, then atomically rewrite. - let mut entries = load_history(); + let mut entries = load_history_from(path); if entries.last().map(String::as_str) == Some(trimmed) { // De-dupe consecutive duplicates — repeated submission of the // same prompt shouldn't bloat the file. @@ -81,7 +89,7 @@ pub fn append_history(entry: &str) { } let payload = entries.join("\n") + "\n"; - if let Err(err) = crate::utils::write_atomic(&path, payload.as_bytes()) { + if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) { tracing::warn!( "Failed to persist composer history at {}: {err}", path.display() @@ -93,94 +101,75 @@ pub fn append_history(entry: &str) { mod tests { use super::*; - fn with_temp_home(f: impl FnOnce() -> R) -> R { - // Use the crate-wide test env mutex so we don't race with other - // tests (config, restore, etc.) that also mutate HOME. - let _guard = crate::test_support::lock_test_env(); + /// Tests use the path-injecting `*_from` / `*_to` helpers so they + /// don't have to mutate `HOME` (which is not honored by + /// `dirs::home_dir()` on Windows — it reads `USERPROFILE` / + /// `SHGetKnownFolderPath` instead). This makes the suite portable + /// across all three CI runners without per-platform env juggling. + fn temp_history_path() -> (tempfile::TempDir, PathBuf) { let tmp = tempfile::tempdir().expect("tempdir"); - let prev = std::env::var_os("HOME"); - // SAFETY: env mutation is serialized by the lock above. - unsafe { std::env::set_var("HOME", tmp.path()) }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); - match prev { - Some(v) => unsafe { std::env::set_var("HOME", v) }, - None => unsafe { std::env::remove_var("HOME") }, - } - match result { - Ok(r) => r, - Err(p) => std::panic::resume_unwind(p), - } + let path = tmp.path().join(HISTORY_FILE_NAME); + (tmp, path) } #[test] fn append_and_load_round_trip() { - with_temp_home(|| { - append_history("first"); - append_history("second"); - append_history("third"); - let history = load_history(); - assert_eq!(history, vec!["first", "second", "third"]); - }); + let (_tmp, path) = temp_history_path(); + append_history_to(&path, "first"); + append_history_to(&path, "second"); + append_history_to(&path, "third"); + assert_eq!(load_history_from(&path), vec!["first", "second", "third"]); } #[test] fn slash_commands_skipped() { - with_temp_home(|| { - append_history("/help"); - append_history("real prompt"); - append_history("/cost"); - let history = load_history(); - assert_eq!(history, vec!["real prompt"]); - }); + let (_tmp, path) = temp_history_path(); + append_history_to(&path, "/help"); + append_history_to(&path, "real prompt"); + append_history_to(&path, "/cost"); + assert_eq!(load_history_from(&path), vec!["real prompt"]); } #[test] fn empty_and_whitespace_skipped() { - with_temp_home(|| { - append_history(""); - append_history(" "); - append_history("\n\t"); - append_history("real"); - let history = load_history(); - assert_eq!(history, vec!["real"]); - }); + let (_tmp, path) = temp_history_path(); + append_history_to(&path, ""); + append_history_to(&path, " "); + append_history_to(&path, "\n\t"); + append_history_to(&path, "real"); + assert_eq!(load_history_from(&path), vec!["real"]); } #[test] fn consecutive_duplicates_deduped() { - with_temp_home(|| { - append_history("same"); - append_history("same"); - append_history("same"); - append_history("different"); - append_history("same"); - let history = load_history(); - assert_eq!(history, vec!["same", "different", "same"]); - }); + let (_tmp, path) = temp_history_path(); + append_history_to(&path, "same"); + append_history_to(&path, "same"); + append_history_to(&path, "same"); + append_history_to(&path, "different"); + append_history_to(&path, "same"); + assert_eq!(load_history_from(&path), vec!["same", "different", "same"]); } #[test] fn pruned_to_cap_at_append_time() { - with_temp_home(|| { - for i in 0..(MAX_HISTORY_ENTRIES + 50) { - append_history(&format!("entry {i}")); - } - let history = load_history(); - assert_eq!(history.len(), MAX_HISTORY_ENTRIES); - // Newest entries survive; oldest 50 were pruned. - assert_eq!(history.first().map(String::as_str), Some("entry 50")); - assert_eq!( - history.last().map(String::as_str), - Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref() - ); - }); + let (_tmp, path) = temp_history_path(); + for i in 0..(MAX_HISTORY_ENTRIES + 50) { + append_history_to(&path, &format!("entry {i}")); + } + let history = load_history_from(&path); + assert_eq!(history.len(), MAX_HISTORY_ENTRIES); + // Newest entries survive; oldest 50 were pruned. + assert_eq!(history.first().map(String::as_str), Some("entry 50")); + assert_eq!( + history.last().map(String::as_str), + Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref() + ); } #[test] fn missing_file_loads_empty() { - with_temp_home(|| { - let history = load_history(); - assert!(history.is_empty()); - }); + let (_tmp, path) = temp_history_path(); + assert!(load_history_from(&path).is_empty()); } } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs new file mode 100644 index 00000000..75519192 --- /dev/null +++ b/crates/tui/src/config_ui.rs @@ -0,0 +1,940 @@ +#[cfg(feature = "web")] +use std::net::SocketAddr; +#[cfg(feature = "web")] +use std::process::Command; +#[cfg(feature = "web")] +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::commands; +use crate::config::{Config, StatusItem, normalize_model_name}; +use crate::localization::{normalize_configured_locale, resolve_locale}; +use crate::settings::Settings; +use crate::tui::app::{ + App, AppMode, ComposerDensity, ReasoningEffort, SidebarFocus, TranscriptSpacing, +}; +use crate::tui::approval::ApprovalMode; + +#[cfg(feature = "web")] +use schemaui::web::session::{ServeOptions, WebSessionBuilder, bind_session}; +#[cfg(feature = "tui")] +use schemaui::{FrontendOptions, SchemaUI, UiOptions}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigUiMode { + Native, + Tui, + Web, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct ConfigUiDocument { + pub runtime: RuntimeSection, + pub settings: SettingsSection, + pub config: ConfigSection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct RuntimeSection { + #[schemars(title = "Current model")] + pub model: String, + pub approval_mode: ApprovalModeValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct SettingsSection { + pub auto_compact: bool, + pub calm_mode: bool, + pub low_motion: bool, + pub fancy_animations: bool, + pub paste_burst_detection: bool, + pub show_thinking: bool, + pub show_tool_details: bool, + pub locale: UiLocale, + pub composer_density: ComposerDensityValue, + pub composer_border: bool, + pub transcript_spacing: TranscriptSpacingValue, + pub default_mode: DefaultModeValue, + #[schemars(range(min = 10, max = 50))] + pub sidebar_width: u16, + pub sidebar_focus: SidebarFocusValue, + #[schemars(range(min = 0))] + pub max_history: usize, + pub default_model: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct ConfigSection { + pub mcp_config_path: String, + pub reasoning_effort: ReasoningEffortValue, + #[schemars(title = "Status line items")] + pub status_items: Vec, +} + +#[derive(Debug, Clone)] +pub struct ConfigUiApplyOutcome { + pub changed: bool, + pub final_message: String, + pub requires_engine_sync: bool, +} + +#[cfg(feature = "web")] +#[derive(Debug)] +pub struct WebConfigSession { + #[allow(dead_code)] + task: tokio::task::JoinHandle<()>, + pub receiver: tokio::sync::mpsc::UnboundedReceiver, + pub addr: SocketAddr, +} + +#[cfg(not(feature = "web"))] +#[derive(Debug)] +pub struct WebConfigSession { + #[allow(dead_code)] + pub receiver: tokio::sync::mpsc::UnboundedReceiver, +} + +#[cfg(test)] +impl WebConfigSession { + pub(crate) fn for_test( + receiver: tokio::sync::mpsc::UnboundedReceiver, + ) -> Self { + #[cfg(feature = "web")] + { + Self { + task: tokio::spawn(async {}), + receiver, + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + } + } + #[cfg(not(feature = "web"))] + { + Self { receiver } + } + } +} + +#[cfg_attr(not(feature = "web"), allow(dead_code))] +#[derive(Debug, Clone)] +pub enum WebConfigSessionEvent { + Draft(ConfigUiDocument), + Committed(ConfigUiDocument), + Failed(String), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalModeValue { + Auto, + Suggest, + Never, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub enum UiLocale { + #[serde(rename = "auto")] + #[schemars(rename = "auto")] + Auto, + #[serde(rename = "en")] + #[schemars(rename = "en")] + En, + #[serde(rename = "ja")] + #[schemars(rename = "ja")] + Ja, + #[serde(rename = "zh-Hans")] + #[schemars(rename = "zh-Hans")] + ZhHans, + #[serde(rename = "pt-BR")] + #[schemars(rename = "pt-BR")] + PtBr, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ComposerDensityValue { + Compact, + Comfortable, + Spacious, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TranscriptSpacingValue { + Compact, + Comfortable, + Spacious, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DefaultModeValue { + Agent, + Plan, + Yolo, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SidebarFocusValue { + Auto, + Plan, + Todos, + Tasks, + Agents, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningEffortValue { + Off, + Low, + Medium, + High, + Max, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StatusItemValue { + Mode, + Model, + Cost, + Status, + Coherence, + Agents, + ReasoningReplay, + Cache, + ContextPercent, + GitBranch, + LastToolElapsed, + RateLimit, +} + +pub fn parse_mode(arg: Option<&str>) -> Result { + let raw = arg.unwrap_or("").trim(); + // Bare `/config` opens the legacy native modal — it matches the rest + // of the deepseek-tui navy chrome out of the box. Power users can + // opt into the schemaui-driven editor with `/config tui`, or the + // browser surface with `/config web` (web feature only). + if raw.is_empty() || raw.eq_ignore_ascii_case("native") { + return Ok(ConfigUiMode::Native); + } + if raw.eq_ignore_ascii_case("tui") { + return Ok(ConfigUiMode::Tui); + } + if raw.eq_ignore_ascii_case("web") { + return Ok(ConfigUiMode::Web); + } + Err("Usage: /config [native|tui|web]".to_string()) +} + +pub fn build_document(app: &App, config: &Config) -> Result { + let settings = Settings::load().unwrap_or_default(); + let reasoning_effort = config + .reasoning_effort() + .map(ReasoningEffortValue::from_setting) + .unwrap_or_else(|| app.reasoning_effort.into()); + let default_model = settings.default_model.clone(); + let status_items = app.status_items.iter().copied().map(Into::into).collect(); + Ok(ConfigUiDocument { + runtime: RuntimeSection { + model: app.model.clone(), + approval_mode: app.approval_mode.into(), + }, + settings: SettingsSection { + auto_compact: settings.auto_compact, + calm_mode: settings.calm_mode, + low_motion: settings.low_motion, + fancy_animations: settings.fancy_animations, + paste_burst_detection: settings.paste_burst_detection, + show_thinking: settings.show_thinking, + show_tool_details: settings.show_tool_details, + locale: UiLocale::from_setting(&settings.locale)?, + composer_density: settings.composer_density.as_str().into(), + composer_border: settings.composer_border, + transcript_spacing: settings.transcript_spacing.as_str().into(), + default_mode: settings.default_mode.as_str().into(), + sidebar_width: settings.sidebar_width_percent, + sidebar_focus: settings.sidebar_focus.as_str().into(), + max_history: settings.max_input_history, + default_model, + }, + config: ConfigSection { + mcp_config_path: app.mcp_config_path.display().to_string(), + reasoning_effort, + status_items, + }, + }) +} + +pub fn build_schema() -> Value { + let mut schema = serde_json::to_value(schema_for!(ConfigUiDocument)).expect("config ui schema"); + schema["title"] = Value::String("DeepSeek TUI Config".to_string()); + schema["description"] = + Value::String("Edit runtime and persisted TUI configuration.".to_string()); + schema +} + +#[cfg(feature = "tui")] +pub fn run_tui_editor(app: &App, config: &Config) -> Result { + let document = build_document(app, config)?; + let value = SchemaUI::new(serde_json::to_value(document.clone())?) + .with_schema(build_schema()) + .with_title("DeepSeek TUI Config") + .with_description("Edit persisted settings and live runtime knobs.") + .run(FrontendOptions::Tui( + UiOptions::default() + .with_confirm_exit(true) + .with_bool_labels("On", "Off") + .with_integer_step(1) + .with_integer_fast_step(5) + .with_help(true), + ))?; + parse_document(value) +} + +#[cfg(feature = "web")] +pub async fn start_web_editor(app: &App, config: &Config) -> Result { + let initial = serde_json::to_value(build_document(app, config)?)?; + let session = WebSessionBuilder::new(build_schema()) + .with_initial_data(initial) + .with_title("DeepSeek TUI Config") + .with_description("Save updates the browser draft. Exit commits changes back to the TUI.") + .build()?; + let bound = bind_session(session, ServeOptions::default()).await?; + let addr = bound.local_addr(); + let url = format!("http://{addr}"); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let app_snapshot = build_document(app, config)?; + let task = tokio::spawn(async move { + let poll_tx = tx.clone(); + let poll_url = format!("{url}/api/session"); + let poll_task = tokio::spawn(async move { + let client = reqwest::Client::new(); + let mut last: Option = Some(app_snapshot); + loop { + tokio::time::sleep(Duration::from_millis(750)).await; + let response = match client.get(&poll_url).send().await { + Ok(response) => response, + Err(err) => { + let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!( + "config web poll failed: {err}" + ))); + break; + } + }; + if !response.status().is_success() { + continue; + } + let body: Value = match response.json().await { + Ok(body) => body, + Err(err) => { + let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!( + "config web decode failed: {err}" + ))); + break; + } + }; + let Some(data) = body.get("data") else { + continue; + }; + let doc = match parse_document(data.clone()) { + Ok(doc) => doc, + Err(_) => continue, + }; + if last.as_ref() == Some(&doc) { + continue; + } + let _ = poll_tx.send(WebConfigSessionEvent::Draft(doc.clone())); + last = Some(doc); + } + }); + + let result = bound.run().await; + poll_task.abort(); + match result { + Ok(value) => match parse_document(value) { + Ok(doc) => { + let _ = tx.send(WebConfigSessionEvent::Committed(doc)); + } + Err(err) => { + let _ = tx.send(WebConfigSessionEvent::Failed(format!( + "config web result decode failed: {err}" + ))); + } + }, + Err(err) => { + let _ = tx.send(WebConfigSessionEvent::Failed(format!( + "config web session failed: {err}" + ))); + } + } + }); + Ok(WebConfigSession { + task, + receiver: rx, + addr, + }) +} + +pub fn apply_document( + doc: ConfigUiDocument, + app: &mut App, + config: &mut Config, + persist: bool, +) -> Result { + validate_document(&doc)?; + let mut notes = Vec::new(); + let previous_compaction = app.compaction_config(); + let previous_reasoning_effort = app.reasoning_effort; + + for (key, value) in [ + ("model", doc.runtime.model.as_str()), + ("approval_mode", doc.runtime.approval_mode.as_setting()), + ("auto_compact", bool_str(doc.settings.auto_compact)), + ("calm_mode", bool_str(doc.settings.calm_mode)), + ("low_motion", bool_str(doc.settings.low_motion)), + ("fancy_animations", bool_str(doc.settings.fancy_animations)), + ( + "paste_burst_detection", + bool_str(doc.settings.paste_burst_detection), + ), + ("show_thinking", bool_str(doc.settings.show_thinking)), + ( + "show_tool_details", + bool_str(doc.settings.show_tool_details), + ), + ("locale", doc.settings.locale.as_setting()), + ( + "composer_density", + doc.settings.composer_density.as_setting(), + ), + ("composer_border", bool_str(doc.settings.composer_border)), + ( + "transcript_spacing", + doc.settings.transcript_spacing.as_setting(), + ), + ("default_mode", doc.settings.default_mode.as_setting()), + ("sidebar_width", &doc.settings.sidebar_width.to_string()), + ("sidebar_focus", doc.settings.sidebar_focus.as_setting()), + ("max_history", &doc.settings.max_history.to_string()), + ("mcp_config_path", doc.config.mcp_config_path.as_str()), + ] { + let result = commands::set_config_value(app, key, value, persist); + if result.is_error { + bail!( + "{}", + result + .message + .unwrap_or_else(|| "config update failed".to_string()) + ); + } + if let Some(message) = result.message { + notes.push(message); + } + } + + // default_model is only applied when persisting (it controls the model + // for future sessions). Processing it in the main loop would overwrite + // the runtime model the user just chose when persist=false (#346-fix). + if persist { + let default_model_val = doc.settings.default_model.as_deref().unwrap_or("default"); + let result = commands::set_config_value(app, "default_model", default_model_val, true); + if result.is_error { + bail!( + "{}", + result + .message + .unwrap_or_else(|| "default_model update failed".to_string()) + ); + } + if let Some(message) = result.message { + notes.push(message); + } + } + + apply_reasoning_effort(app, config, doc.config.reasoning_effort, persist)?; + let requires_engine_sync = app.compaction_config() != previous_compaction + || app.reasoning_effort != previous_reasoning_effort; + + let new_status_items = parse_status_items(&doc.config.status_items); + if app.status_items != new_status_items { + app.status_items = new_status_items.clone(); + app.needs_redraw = true; + if persist { + let path = commands::persist_status_items(&new_status_items)?; + notes.push(format!("status_items saved to {}", path.display())); + } else { + notes.push("status_items updated for this session".to_string()); + } + } + + if persist { + reload_runtime_config(app, config)?; + notes.extend(config_reload_notes(app, config)); + } + let changed = !notes.is_empty(); + let final_message = if notes.is_empty() { + if persist { + "Config unchanged".to_string() + } else { + "Runtime config unchanged".to_string() + } + } else { + notes.last().cloned().unwrap_or_default() + }; + Ok(ConfigUiApplyOutcome { + changed, + final_message, + requires_engine_sync, + }) +} + +pub fn parse_document(value: Value) -> Result { + serde_json::from_value(value).context("failed to decode config ui document") +} + +#[cfg(feature = "web")] +pub fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(url); + command + }; + #[cfg(target_os = "linux")] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(url); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "start", "", url]); + command + }; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + return Err(anyhow::anyhow!( + "browser opening is unsupported on this platform" + )); + + let status = command + .status() + .context("failed to launch browser command")?; + if !status.success() { + bail!("browser command exited with status {status}"); + } + Ok(()) +} + +fn validate_document(doc: &ConfigUiDocument) -> Result<()> { + if normalize_model_name(&doc.runtime.model).is_none() { + bail!("invalid model '{}'", doc.runtime.model); + } + if doc.config.mcp_config_path.trim().is_empty() { + bail!("mcp_config_path cannot be empty"); + } + Ok(()) +} + +fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> { + let reloaded = Config::load(app.config_path.clone(), app.config_profile.as_deref())?; + *config = reloaded.clone(); + app.api_provider = reloaded.api_provider(); + app.reasoning_effort = ReasoningEffort::from_setting( + reloaded + .reasoning_effort() + .unwrap_or_else(|| app.reasoning_effort.as_setting()), + ); + app.update_model_compaction_budget(); + app.mcp_config_path = reloaded.mcp_config_path(); + app.skills_dir = reloaded.skills_dir(); + app.ui_locale = resolve_locale(&Settings::load().unwrap_or_default().locale); + Ok(()) +} + +fn config_reload_notes(app: &App, config: &Config) -> Vec { + let mut notes = Vec::new(); + notes.push("Config saved and reloaded".to_string()); + if app.mcp_restart_required { + notes.push(format!( + "MCP tool pool still requires restart after {}", + config.mcp_config_path().display() + )); + } + notes +} + +fn apply_reasoning_effort( + app: &mut App, + config: &mut Config, + value: ReasoningEffortValue, + persist: bool, +) -> Result<()> { + let effort: ReasoningEffort = value.into(); + app.reasoning_effort = effort; + app.update_model_compaction_budget(); + if persist { + commands::persist_root_string_key("reasoning_effort", effort.as_setting())?; + } + config.reasoning_effort = Some(effort.as_setting().to_string()); + Ok(()) +} + +fn parse_status_items(items: &[StatusItemValue]) -> Vec { + items.iter().copied().map(Into::into).collect() +} + +impl ApprovalModeValue { + fn as_setting(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Suggest => "suggest", + Self::Never => "never", + } + } +} + +impl UiLocale { + fn as_setting(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::En => "en", + Self::Ja => "ja", + Self::ZhHans => "zh-Hans", + Self::PtBr => "pt-BR", + } + } + + fn from_setting(value: &str) -> Result { + match normalize_configured_locale(value) { + Some("auto") => Ok(Self::Auto), + Some("en") => Ok(Self::En), + Some("ja") => Ok(Self::Ja), + Some("zh-Hans") => Ok(Self::ZhHans), + Some("pt-BR") => Ok(Self::PtBr), + Some(other) => bail!("unsupported locale '{other}'"), + None => bail!("invalid locale '{value}'"), + } + } +} + +impl ComposerDensityValue { + fn as_setting(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Comfortable => "comfortable", + Self::Spacious => "spacious", + } + } +} + +impl TranscriptSpacingValue { + fn as_setting(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Comfortable => "comfortable", + Self::Spacious => "spacious", + } + } +} + +impl DefaultModeValue { + fn as_setting(self) -> &'static str { + match self { + Self::Agent => "agent", + Self::Plan => "plan", + Self::Yolo => "yolo", + } + } +} + +impl SidebarFocusValue { + fn as_setting(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Plan => "plan", + Self::Todos => "todos", + Self::Tasks => "tasks", + Self::Agents => "agents", + } + } +} + +impl From for ApprovalModeValue { + fn from(value: ApprovalMode) -> Self { + match value { + ApprovalMode::Auto => Self::Auto, + ApprovalMode::Suggest => Self::Suggest, + ApprovalMode::Never => Self::Never, + } + } +} + +impl From for ReasoningEffortValue { + fn from(value: ReasoningEffort) -> Self { + match value { + ReasoningEffort::Off => Self::Off, + ReasoningEffort::Low => Self::Low, + ReasoningEffort::Medium => Self::Medium, + ReasoningEffort::High => Self::High, + ReasoningEffort::Max => Self::Max, + } + } +} + +impl ReasoningEffortValue { + fn from_setting(value: &str) -> Self { + match ReasoningEffort::from_setting(value) { + ReasoningEffort::Off => Self::Off, + ReasoningEffort::Low => Self::Low, + ReasoningEffort::Medium => Self::Medium, + ReasoningEffort::High => Self::High, + ReasoningEffort::Max => Self::Max, + } + } +} + +impl From for ReasoningEffort { + fn from(value: ReasoningEffortValue) -> Self { + match value { + ReasoningEffortValue::Off => Self::Off, + ReasoningEffortValue::Low => Self::Low, + ReasoningEffortValue::Medium => Self::Medium, + ReasoningEffortValue::High => Self::High, + ReasoningEffortValue::Max => Self::Max, + } + } +} + +impl From<&str> for ComposerDensityValue { + fn from(value: &str) -> Self { + match ComposerDensity::from_setting(value) { + ComposerDensity::Compact => Self::Compact, + ComposerDensity::Comfortable => Self::Comfortable, + ComposerDensity::Spacious => Self::Spacious, + } + } +} + +impl From<&str> for TranscriptSpacingValue { + fn from(value: &str) -> Self { + match TranscriptSpacing::from_setting(value) { + TranscriptSpacing::Compact => Self::Compact, + TranscriptSpacing::Comfortable => Self::Comfortable, + TranscriptSpacing::Spacious => Self::Spacious, + } + } +} + +impl From<&str> for DefaultModeValue { + fn from(value: &str) -> Self { + match AppMode::from_setting(value) { + AppMode::Agent => Self::Agent, + AppMode::Plan => Self::Plan, + AppMode::Yolo => Self::Yolo, + } + } +} + +impl From<&str> for SidebarFocusValue { + fn from(value: &str) -> Self { + match SidebarFocus::from_setting(value) { + SidebarFocus::Auto => Self::Auto, + SidebarFocus::Plan => Self::Plan, + SidebarFocus::Todos => Self::Todos, + SidebarFocus::Tasks => Self::Tasks, + SidebarFocus::Agents => Self::Agents, + } + } +} + +impl From for StatusItemValue { + fn from(value: StatusItem) -> Self { + match value { + StatusItem::Mode => Self::Mode, + StatusItem::Model => Self::Model, + StatusItem::Cost => Self::Cost, + StatusItem::Status => Self::Status, + StatusItem::Coherence => Self::Coherence, + StatusItem::Agents => Self::Agents, + StatusItem::ReasoningReplay => Self::ReasoningReplay, + StatusItem::Cache => Self::Cache, + StatusItem::ContextPercent => Self::ContextPercent, + StatusItem::GitBranch => Self::GitBranch, + StatusItem::LastToolElapsed => Self::LastToolElapsed, + StatusItem::RateLimit => Self::RateLimit, + } + } +} + +impl From for StatusItem { + fn from(value: StatusItemValue) -> Self { + match value { + StatusItemValue::Mode => Self::Mode, + StatusItemValue::Model => Self::Model, + StatusItemValue::Cost => Self::Cost, + StatusItemValue::Status => Self::Status, + StatusItemValue::Coherence => Self::Coherence, + StatusItemValue::Agents => Self::Agents, + StatusItemValue::ReasoningReplay => Self::ReasoningReplay, + StatusItemValue::Cache => Self::Cache, + StatusItemValue::ContextPercent => Self::ContextPercent, + StatusItemValue::GitBranch => Self::GitBranch, + StatusItemValue::LastToolElapsed => Self::LastToolElapsed, + StatusItemValue::RateLimit => Self::RateLimit, + } + } +} + +fn bool_str(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::test_support::lock_test_env; + use crate::tui::app::{App, TuiOptions}; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn app() -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("."), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: false, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn build_document_reflects_app_state() { + let app = app(); + let config = Config::default(); + let doc = build_document(&app, &config).expect("document"); + assert_eq!(doc.runtime.model, app.model); + assert_eq!(doc.runtime.approval_mode, ApprovalModeValue::Suggest); + assert_eq!(doc.config.reasoning_effort, ReasoningEffortValue::Max); + } + + #[test] + fn schema_contains_typed_enums() { + let schema = build_schema(); + let approval_mode = &schema["$defs"]["ApprovalModeValue"]["enum"]; + assert_eq!( + approval_mode, + &serde_json::json!(["auto", "suggest", "never"]) + ); + let locale = &schema["$defs"]["UiLocale"]["enum"]; + assert_eq!( + locale, + &serde_json::json!(["auto", "en", "ja", "zh-Hans", "pt-BR"]) + ); + } + + #[test] + fn parse_document_roundtrip() { + let _lock = lock_test_env(); + let app = app(); + let config = Config::default(); + let doc = build_document(&app, &config).expect("document"); + let value = serde_json::to_value(doc.clone()).expect("json"); + let parsed = parse_document(value).expect("parsed"); + assert_eq!(parsed, doc); + } + + #[test] + fn session_only_apply_keeps_runtime_overrides_and_skips_reload() { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!( + "deepseek-config-ui-session-only-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir"); + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::write( + &config_path, + r#" +model = "deepseek-v4-pro" +reasoning_effort = "max" +mcp_config_path = "disk-mcp.json" +"#, + ) + .expect("seed config"); + + let mut app = app(); + app.config_path = Some(config_path.clone()); + app.model = "deepseek-v4-pro".to_string(); + app.mcp_config_path = PathBuf::from("disk-mcp.json"); + app.reasoning_effort = ReasoningEffort::Max; + let mut config = Config::load(Some(config_path), None).expect("load config"); + + let mut doc = build_document(&app, &config).expect("document"); + doc.runtime.model = "deepseek-v4-flash".to_string(); + doc.config.reasoning_effort = ReasoningEffortValue::Low; + doc.config.mcp_config_path = "session-mcp.json".to_string(); + + let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply"); + + assert!(outcome.changed); + assert!(outcome.requires_engine_sync); + assert_eq!(app.model, "deepseek-v4-flash"); + assert_eq!(app.reasoning_effort, ReasoningEffort::Low); + assert_eq!(app.mcp_config_path, PathBuf::from("session-mcp.json")); + assert_eq!( + config.reasoning_effort.as_deref(), + Some(ReasoningEffort::Low.as_setting()) + ); + assert_eq!( + config.mcp_config_path.as_deref(), + Some("disk-mcp.json"), + "session-only apply must not reload persisted config back into runtime state" + ); + } + + #[test] + fn status_item_only_apply_does_not_require_engine_sync() { + let _lock = lock_test_env(); + let mut app = app(); + let mut config = Config::default(); + let mut doc = build_document(&app, &config).expect("document"); + doc.config.status_items = vec![StatusItemValue::Cost, StatusItemValue::Model]; + + let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply"); + + assert!(outcome.changed); + assert!(!outcome.requires_engine_sync); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ddc7e4dd..f740f1df 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -20,6 +20,7 @@ mod commands; mod compaction; mod composer_history; mod config; +mod config_ui; mod core; mod cycle_manager; mod deepseek_theme; @@ -2887,6 +2888,8 @@ async fn run_interactive( tui::TuiOptions { model, workspace, + config_path: cli.config.clone(), + config_profile: cli.profile.clone(), allow_shell: cli.yolo || config.allow_shell(), use_alt_screen, use_mouse_capture, diff --git a/crates/tui/src/tools/fetch_url.rs b/crates/tui/src/tools/fetch_url.rs index a0f12e84..031e6d3c 100644 --- a/crates/tui/src/tools/fetch_url.rs +++ b/crates/tui/src/tools/fetch_url.rs @@ -142,12 +142,15 @@ impl ToolSpec for FetchUrlTool { )); } + // Extract host once for reuse across network policy + SSRF checks. + let url_host = host_from_url(&url); + // Per-domain network policy gate (#135). If no policy is attached // (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0. if let Some(decider) = context.network_policy.as_ref() - && let Some(host) = host_from_url(&url) + && let Some(ref host) = url_host { - match decider.evaluate(&host, "fetch_url") { + match decider.evaluate(host, "fetch_url") { Decision::Allow => {} Decision::Deny => { return Err(ToolError::permission_denied(format!( @@ -163,19 +166,64 @@ impl ToolSpec for FetchUrlTool { } } + // SSRF protection: resolve hostname and reject private/link-local/loopback IPs. + // Prevents LLM-prompted requests to cloud metadata (169.254.169.254), + // localhost services, and internal networks. + // Pin the validated IP via ClientBuilder::resolve() to close the DNS rebinding + // TOCTOU window — reqwest will use the pinned IP instead of re-resolving. + let mut dns_pinning = None; // (hostname, validated_ip) + if let Some(host) = &url_host { + if host == "localhost" || host == "localhost.localdomain" { + return Err(ToolError::permission_denied( + "requests to localhost are not allowed", + )); + } + if let Ok(ip) = host.parse::() { + if is_restricted_ip(&ip) { + return Err(ToolError::permission_denied(format!( + "IP {ip} is a restricted address (private/loopback/link-local)" + ))); + } + } else if let Ok(addrs) = tokio::net::lookup_host((&**host, 0u16)).await { + let mut first_valid: Option = None; + for addr in addrs { + if is_restricted_ip(&addr.ip()) { + return Err(ToolError::permission_denied(format!( + "resolved IP {} is a restricted address (private/loopback/link-local)", + addr.ip() + ))); + } + if first_valid.is_none() { + first_valid = Some(addr.ip()); + } + } + if let Some(validated_ip) = first_valid { + dns_pinning = Some((host.clone(), validated_ip)); + } + } + // If DNS resolution fails, let the HTTP request proceed and fail naturally. + } + let format = Format::parse(input.get("format").and_then(Value::as_str))?; let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES); let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS); - let client = reqwest::Client::builder() + let mut client_builder = reqwest::Client::builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) - .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS)) - .build() - .map_err(|e| { - ToolError::execution_failed(format!("failed to build HTTP client: {e}")) - })?; + .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS)); + + // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will + // connect to the validated IP directly instead of re-resolving. + if let Some((hostname, validated_ip)) = dns_pinning { + client_builder = + client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0)); + } + + let client = client_builder.build().map_err(|e| { + ToolError::execution_failed(format!("failed to build HTTP client: {e}")) + })?; let resp = client .get(&url) @@ -244,6 +292,46 @@ impl ToolSpec for FetchUrlTool { } } +/// Check if an IP address is loopback, private, link-local, cloud-metadata, +/// multicast, or reserved — all addresses that should not be reachable via +/// an LLM-initiated fetch_url request (SSRF prevention). +fn is_restricted_ip(ip: &std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_multicast() + || v4.is_broadcast() + || v4.is_unspecified() + // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space) + || matches!(v4.octets(), [100, 64..=127, ..]) + // 169.254.169.254 — cloud metadata (AWS/GCP/Azure) + || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254)) + // 198.18.0.0/15 — IETF benchmark testing + || matches!(v4.octets(), [198, 18..=19, ..]) + // 240.0.0.0/4 — reserved (former Class E) + || v4.octets()[0] >= 240 + } + std::net::IpAddr::V6(v6) => { + // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4 + // to prevent bypass via ::ffff:127.0.0.1 etc. + if v6.is_unspecified() + || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..]) + { + return true; + } + if let Some(v4) = v6.to_ipv4_mapped() { + return is_restricted_ip(&std::net::IpAddr::V4(v4)); + } + v6.is_loopback() + || v6.is_multicast() + || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7 + || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10 + } + } +} + /// Strip `