diff --git a/AGENTS.md b/AGENTS.md index efd53c54b..98870e36a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Development Workflow +- **GitHub is the durable source of truth** - For non-trivial repo work, use GitHub Issues, GitHub Projects, and repo docs as the canonical record of scope, acceptance criteria, blockers, decisions, and status. Keep issues/project fields/linked PRs updated religiously; do not rely on ephemeral chat or local notes as the final state. - **Commit as you go** - Make small, focused commits after completing each feature or fix - If the git state is not clean, or there are other agents working in the codebase in parallel, do your best to still commit your work. - **Push when done** - Push all commits to remote when finishing a task or session @@ -9,6 +10,10 @@ - **Rebuild when done** - When you are done making changes, build the source. - **Bump version for releases** - Update version in `Cargo.toml` when making releases. When cutting a new release, look at all the changes that happened since the last release and determine what the version bump should be ie patch or minor, etc. - **Remote builds available** - Use `scripts/remote_build.sh` to offload heavy cargo work to another machine. If your build is terminated, likely is because there are not enough resources on this machine to build. use remote build in that case. Try checking the resource avaliablity on the machine before you run a build. +- **Protect context aggressively** - Every token is gold. Keep main-thread output to checkpoint decisions and concise evidence; route noisy discovery/raw logs to background tasks, subagents, files, side panels, or cached tool outputs. +- **Delegate substantial independent work** - Prefer subagents/child sessions for parallelizable discovery, deep investigation, or long-running validation. +- **Use one-shot mechanical subagents** - For mechanical validation, status, and publishing work, such as long cargo validations, git status/diff summaries, PR/issue creation checks, and final repo audits, use no-context one-shot subagents and return only compact results to the main session. +- **Background means non-UI** - Run background work in non-interactive, non-focus-stealing jobs. Do not spawn headed terminals or steal window focus unless the user explicitly asks. ## Logs - Logs are written to `~/.jcode/logs/` (daily files like `jcode-YYYY-MM-DD.log`). @@ -24,4 +29,3 @@ - `~/.jcode/builds/canary/jcode` still exists for canary/testing flows, but it is not the primary self-dev install path. - On Windows, the equivalents are `%LOCALAPPDATA%\\jcode\\bin\\jcode.exe` for the launcher, `%LOCALAPPDATA%\\jcode\\builds\\stable\\jcode.exe` for stable, and `%LOCALAPPDATA%\\jcode\\builds\\versions\\\\jcode.exe` for immutable installs; `scripts/install.ps1` currently installs the stable channel. - Ensure `~/.local/bin` is **before** `~/.cargo/bin` in `PATH`. - diff --git a/Cargo.lock b/Cargo.lock index 895c13c14..30ce4cffe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,17 +91,36 @@ dependencies = [ "bitflags 2.10.0", "cc", "cesu8", - "jni", + "jni 0.21.1", "jni-sys 0.3.1", "libc", "log", - "ndk", + "ndk 0.8.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", "num_enum", "thiserror 1.0.69", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.17", +] + [[package]] name = "android-properties" version = "0.2.2" @@ -204,10 +223,10 @@ dependencies = [ "image", "log", "objc2 0.6.3", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "windows-sys 0.60.2", @@ -241,6 +260,15 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -855,6 +883,15 @@ dependencies = [ "bit-vec 0.6.3", ] +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec 0.7.0", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -870,6 +907,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + [[package]] name = "bit-vec" version = "0.8.0" @@ -946,6 +989,15 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1036,13 +1088,39 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + [[package]] name = "calloop-wayland-source" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" dependencies = [ - "calloop", + "calloop 0.12.4", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", @@ -1373,19 +1451,21 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75acbfb314aeb4f5210d379af45ed1ec2c98c7f1790bf57b8a4c562ac0c51b71" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" dependencies = [ - "fontdb 0.15.0", - "libm", + "bitflags 2.10.0", + "fontdb 0.16.2", "log", "rangemap", - "rustc-hash", - "rustybuzz 0.11.0", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz 0.14.1", "self_cell", "swash", "sys-locale", + "ttf-parser 0.21.1", "unicode-bidi", "unicode-linebreak", "unicode-script", @@ -1564,6 +1644,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.10.0", + "libloading 0.8.9", + "winapi", +] + [[package]] name = "darling" version = "0.20.11" @@ -1845,6 +1936,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -2169,16 +2266,16 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.15.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020e203f177c0fb250fb19455a252e838d2bbbce1f80f25ecc42402aafa8cd38" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.8.0", + "memmap2", "slotmap", "tinyvec", - "ttf-parser 0.19.2", + "ttf-parser 0.20.0", ] [[package]] @@ -2189,7 +2286,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.9.9", + "memmap2", "slotmap", "tinyvec", "ttf-parser 0.25.1", @@ -2450,7 +2547,7 @@ dependencies = [ "crossbeam-channel", "keyboard-types", "objc2 0.6.3", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "once_cell", "thiserror 2.0.17", "windows-sys 0.59.0", @@ -2492,16 +2589,26 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "glyphon" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a62d0338e4056db6a73221c2fb2e30619452f6ea9651bac4110f51b0f7a7581" +checksum = "b11b1afb04c1a1be055989042258499473d0a9447f16450b433aba10bc2a46e7" dependencies = [ "cosmic-text", "etagere", "lru 0.12.5", - "wgpu", + "rustc-hash 2.1.2", + "wgpu 22.1.0", ] [[package]] @@ -2536,6 +2643,19 @@ dependencies = [ "windows 0.52.0", ] +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + [[package]] name = "gpu-descriptor" version = "0.2.4" @@ -2543,10 +2663,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" dependencies = [ "bitflags 2.10.0", - "gpu-descriptor-types", + "gpu-descriptor-types 0.1.2", "hashbrown 0.14.5", ] +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types 0.2.0", + "hashbrown 0.15.5", +] + [[package]] name = "gpu-descriptor-types" version = "0.1.2" @@ -2556,6 +2687,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "group" version = "0.12.1" @@ -2633,8 +2773,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2938,7 +3076,7 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" dependencies = [ - "block2", + "block2 0.3.0", "dispatch", "objc2 0.4.1", ] @@ -3440,6 +3578,7 @@ name = "jcode-config-types" version = "0.1.0" dependencies = [ "serde", + "toml", ] [[package]] @@ -3466,9 +3605,9 @@ dependencies = [ "pollster", "pulldown-cmark", "serde_json", - "wgpu", + "wgpu 22.1.0", "whoami", - "winit", + "winit 0.30.13", ] [[package]] @@ -3542,8 +3681,8 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "wgpu", - "winit", + "wgpu 0.19.4", + "winit 0.29.15", ] [[package]] @@ -3873,6 +4012,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.17", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -4208,9 +4377,6 @@ name = "lru" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] [[package]] name = "lru" @@ -4297,15 +4463,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "memmap2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" -dependencies = [ - "libc", -] - [[package]] name = "memmap2" version = "0.9.9" @@ -4364,6 +4521,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -4443,7 +4615,28 @@ dependencies = [ "indexmap", "log", "num-traits", - "rustc-hash", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set 0.6.0", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", "spirv", "termcolor", "thiserror 1.0.69", @@ -4491,7 +4684,22 @@ dependencies = [ "bitflags 2.10.0", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -4512,6 +4720,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.29.0" @@ -4665,6 +4882,16 @@ dependencies = [ "objc2-encode 3.0.0", ] +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode 4.1.0", +] + [[package]] name = "objc2" version = "0.6.3" @@ -4674,6 +4901,22 @@ dependencies = [ "objc2-encode 4.1.0", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -4683,7 +4926,43 @@ dependencies = [ "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -4711,17 +4990,54 @@ dependencies = [ ] [[package]] -name = "objc2-encode" -version = "3.0.0" +name = "objc2-core-image" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -4744,6 +5060,98 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -5729,6 +6137,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5935,6 +6352,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -6063,17 +6486,17 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" -version = "0.11.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "bytemuck", "libm", "smallvec", - "ttf-parser 0.20.0", - "unicode-bidi-mirroring 0.1.0", - "unicode-ccc 0.1.2", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring 0.2.0", + "unicode-ccc 0.2.0", "unicode-properties", "unicode-script", ] @@ -6168,8 +6591,21 @@ checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" dependencies = [ "ab_glyph", "log", - "memmap2 0.9.9", - "smithay-client-toolkit", + "memmap2", + "smithay-client-toolkit 0.18.1", + "tiny-skia", +] + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", "tiny-skia", ] @@ -6415,6 +6851,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -6474,20 +6926,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" dependencies = [ "bitflags 2.10.0", - "calloop", - "calloop-wayland-source", + "calloop 0.12.4", + "calloop-wayland-source 0.2.0", "cursor-icon", "libc", "log", - "memmap2 0.9.9", + "memmap2", "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", "wayland-client", "wayland-csd-frame", "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", + "wayland-protocols 0.31.2", + "wayland-protocols-wlr 0.2.0", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols 0.32.12", + "wayland-protocols-wlr 0.3.12", "wayland-scanner", "xkeysym", ] @@ -7415,7 +7892,7 @@ dependencies = [ "bytes", "derive-new", "log", - "memmap2 0.9.9", + "memmap2", "num-integer", "prost", "smallvec", @@ -7456,15 +7933,15 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttf-parser" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" [[package]] name = "ttf-parser" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" [[package]] name = "ttf-parser" @@ -7580,9 +8057,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi-mirroring" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" [[package]] name = "unicode-bidi-mirroring" @@ -7592,9 +8069,9 @@ checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" [[package]] name = "unicode-ccc" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" [[package]] name = "unicode-ccc" @@ -8021,6 +8498,18 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.2.0" @@ -8030,7 +8519,20 @@ dependencies = [ "bitflags 2.10.0", "wayland-backend", "wayland-client", - "wayland-protocols", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", "wayland-scanner", ] @@ -8043,7 +8545,20 @@ dependencies = [ "bitflags 2.10.0", "wayland-backend", "wayland-client", - "wayland-protocols", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", "wayland-scanner", ] @@ -8090,6 +8605,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -8188,7 +8713,32 @@ dependencies = [ "cfg_aliases 0.1.1", "js-sys", "log", - "naga", + "naga 0.19.2", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core 0.19.4", + "wgpu-hal 0.19.5", + "wgpu-types 0.19.2", +] + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga 22.1.0", "parking_lot", "profiling", "raw-window-handle", @@ -8197,9 +8747,9 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", + "wgpu-core 22.1.0", + "wgpu-hal 22.0.0", + "wgpu-types 22.0.0", ] [[package]] @@ -8215,17 +8765,42 @@ dependencies = [ "codespan-reporting", "indexmap", "log", - "naga", + "naga 0.19.2", "once_cell", "parking_lot", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "web-sys", - "wgpu-hal", - "wgpu-types", + "wgpu-hal 0.19.5", + "wgpu-types 0.19.2", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec 0.7.0", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga 22.1.0", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal 22.0.0", + "wgpu-types 22.0.0", ] [[package]] @@ -8236,27 +8811,27 @@ checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" dependencies = [ "android_system_properties", "arrayvec", - "ash", + "ash 0.37.3+1.3.251", "bit-set 0.5.3", "bitflags 2.10.0", "block", "cfg_aliases 0.1.1", "core-graphics-types", - "d3d12", + "d3d12 0.19.0", "glow", - "glutin_wgl_sys", + "glutin_wgl_sys 0.5.0", "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", + "gpu-allocator 0.25.0", + "gpu-descriptor 0.2.4", "hassle-rs", "js-sys", "khronos-egl", "libc", "libloading 0.8.9", "log", - "metal", - "naga", - "ndk-sys", + "metal 0.27.0", + "naga 0.19.2", + "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", "parking_lot", @@ -8264,12 +8839,57 @@ dependencies = [ "range-alloc", "raw-window-handle", "renderdoc-sys", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "wasm-bindgen", "web-sys", - "wgpu-types", + "wgpu-types 0.19.2", + "winapi", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash 0.38.0+1.3.281", + "bit-set 0.6.0", + "bitflags 2.10.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12 22.0.0", + "glow", + "glutin_wgl_sys 0.6.1", + "gpu-alloc", + "gpu-allocator 0.26.0", + "gpu-descriptor 0.3.2", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal 0.29.0", + "naga 22.1.0", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types 22.0.0", "winapi", ] @@ -8284,6 +8904,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "web-sys", +] + [[package]] name = "whoami" version = "1.6.1" @@ -8799,11 +9430,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" dependencies = [ "ahash", - "android-activity", + "android-activity 0.5.2", "atomic-waker", "bitflags 2.10.0", "bytemuck", - "calloop", + "calloop 0.12.4", "cfg_aliases 0.1.1", "core-foundation 0.9.4", "core-graphics", @@ -8812,9 +9443,9 @@ dependencies = [ "js-sys", "libc", "log", - "memmap2 0.9.9", - "ndk", - "ndk-sys", + "memmap2", + "ndk 0.8.0", + "ndk-sys 0.5.0+25.2.9519653", "objc2 0.4.1", "once_cell", "orbclient", @@ -8822,24 +9453,76 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.3.5", "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit", + "sctk-adwaita 0.8.3", + "smithay-client-toolkit 0.18.1", "smol_str", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", "wayland-backend", "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", + "wayland-protocols 0.31.2", + "wayland-protocols-plasma 0.2.0", "web-sys", - "web-time", + "web-time 0.2.4", "windows-sys 0.48.0", "x11-dl", "x11rb", "xkbcommon-dl", ] +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity 0.6.1", + "atomic-waker", + "bitflags 2.10.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita 0.10.1", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", + "wayland-protocols-plasma 0.3.12", + "web-sys", + "web-time 1.1.0", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.5.40" diff --git a/Cargo.toml b/Cargo.toml index d20afc0d4..f11bac502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,11 @@ name = "tui_bench" path = "src/bin/tui_bench.rs" required-features = ["dev-bins"] +[[bin]] +name = "publish_selfdev" +path = "src/bin/publish_selfdev.rs" +required-features = ["dev-bins"] + [dependencies] # Memory allocator (reduces fragmentation for long-running server) tikv-jemallocator = { version = "0.6", features = ["unprefixed_malloc_on_supported_platforms"], optional = true } diff --git a/crates/jcode-build-support/src/paths.rs b/crates/jcode-build-support/src/paths.rs index 3d1a51050..bbee31f66 100644 --- a/crates/jcode-build-support/src/paths.rs +++ b/crates/jcode-build-support/src/paths.rs @@ -79,6 +79,10 @@ pub fn selfdev_binary_path(repo_dir: &Path) -> PathBuf { profile_binary_path(repo_dir, SELFDEV_CARGO_PROFILE) } +pub fn debug_binary_path(repo_dir: &Path) -> PathBuf { + profile_binary_path(repo_dir, "debug") +} + fn binary_mtime(path: &Path) -> Option { std::fs::metadata(path) .ok() @@ -237,15 +241,33 @@ pub fn current_binary_build_time_string() -> Option { } /// Find the best development binary in the repo. -/// Prefers the newest local self-dev or release binary. +/// Prefers the newest local self-dev, release, or debug binary. pub fn find_dev_binary(repo_dir: &Path) -> Option { newest_existing_binary(vec![ (selfdev_binary_path(repo_dir), "repo-selfdev"), (release_binary_path(repo_dir), "repo-release"), + (debug_binary_path(repo_dir), "repo-debug"), ]) .map(|(path, _)| path) } +fn current_exe_is_repo_binary() -> Option { + let exe = std::env::current_exe().ok()?; + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); + let name = exe.file_name()?.to_str()?; + if name != binary_name() { + return None; + } + + let profile = exe.parent()?.file_name()?.to_str()?; + if !matches!(profile, "debug" | "release" | SELFDEV_CARGO_PROFILE) { + return None; + } + + let repo = exe.parent()?.parent()?.parent()?; + if is_jcode_repo(repo) { Some(exe) } else { None } +} + fn home_dir() -> Result { std::env::var("HOME") .map(PathBuf::from) @@ -328,6 +350,18 @@ pub fn update_launcher_symlink_to_stable() -> Result { update_launcher_symlink(&stable) } +/// Check if a path looks like a Cargo test binary. +/// +/// Test binaries live in `target//deps/` and have the test harness as +/// their entry point, meaning they won't recognize jcode CLI flags like +/// `--fresh-spawn` or `--resume`. +fn is_test_binary_path(exe: &Path) -> bool { + exe.parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .is_some_and(|s| s == "deps") +} + /// Resolve which client binary should be considered for launches, updates, and reloads. /// /// Order matters: @@ -362,7 +396,11 @@ pub fn client_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'s return Some(stable); } - std::env::current_exe().ok().map(|exe| (exe, "current")) + // Avoid launching cargo test binaries whose entry point is the test harness. + std::env::current_exe() + .ok() + .filter(|exe| !is_test_binary_path(exe)) + .map(|exe| (exe, "current")) } /// Resolve the binary that the shared daemon should spawn or reload into. @@ -374,6 +412,10 @@ pub fn client_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'s pub fn shared_server_update_candidate( _is_selfdev_session: bool, ) -> Option<(PathBuf, &'static str)> { + if let Some(repo_exe) = current_exe_is_repo_binary() { + return Some((repo_exe, "repo-current")); + } + if let Some(shared_server) = existing_binary(shared_server_binary_path(), "shared-server") { return Some(shared_server); } @@ -382,9 +424,11 @@ pub fn shared_server_update_candidate( return Some(stable); } - std::env::current_exe().ok().map(|exe| (exe, "current")) + std::env::current_exe() + .ok() + .filter(|exe| !is_test_binary_path(exe)) + .map(|exe| (exe, "current")) } - /// Resolve the best binary to use for `/reload`. /// /// This mostly follows `client_update_candidate`, but if a freshly built repo diff --git a/crates/jcode-config-types/Cargo.toml b/crates/jcode-config-types/Cargo.toml index a3c17c356..5bdd85a4b 100644 --- a/crates/jcode-config-types/Cargo.toml +++ b/crates/jcode-config-types/Cargo.toml @@ -6,3 +6,6 @@ publish = false [dependencies] serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +toml = "0.8" diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 7f8963d9f..570283bee 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; /// Compaction mode #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] @@ -597,7 +598,7 @@ pub struct ProviderConfig { pub openai_reasoning_effort: Option, /// OpenAI transport mode (auto|websocket|https) pub openai_transport: Option, - /// OpenAI service tier override (priority|flex) + /// OpenAI service tier override (priority|flex|off) pub openai_service_tier: Option, /// OpenAI native compaction mode: "auto", "explicit", or "off". pub openai_native_compaction_mode: String, @@ -611,6 +612,22 @@ pub struct ProviderConfig { /// Copilot premium request mode: "normal", "one", or "zero" /// "zero" means all requests are free (no premium requests consumed) pub copilot_premium: Option, + /// Provider/profile allowlist for model pickers and auto-selection. + /// + /// Empty means every configured provider is eligible. Non-empty means only + /// these built-in provider keys, OpenAI-compatible profile ids, or named + /// provider profile names should be displayed and auto-selected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enabled_providers: Vec, + /// Per-provider model allowlist. Maps the built-in provider key + /// (e.g. "anthropic", "openai", "gemini", "antigravity") to a list of + /// allowed model identifiers. When a provider has a non-empty entry, + /// the model picker and `/model` command only expose the listed models + /// (substring match, case-insensitive). Prefix a pattern with `=` for an + /// exact match. Providers absent from this map or with an empty list are + /// unrestricted. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub model_allowlist: BTreeMap>, } impl Default for ProviderConfig { @@ -620,12 +637,14 @@ impl Default for ProviderConfig { default_provider: None, openai_reasoning_effort: Some("low".to_string()), openai_transport: None, - openai_service_tier: Some("priority".to_string()), + openai_service_tier: Some("off".to_string()), openai_native_compaction_mode: "auto".to_string(), openai_native_compaction_threshold_tokens: 200_000, cross_provider_failover: CrossProviderFailoverMode::Countdown, same_provider_account_failover: true, copilot_premium: None, + enabled_providers: Vec::new(), + model_allowlist: BTreeMap::new(), } } } @@ -773,3 +792,79 @@ impl Default for GatewayConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_model_allowlist_round_trips_through_toml() { + let mut allow = BTreeMap::new(); + allow.insert( + "anthropic".to_string(), + vec![ + "claude-opus-4-7".to_string(), + "claude-sonnet-4-6".to_string(), + ], + ); + allow.insert("openai".to_string(), vec!["gpt-5.5".to_string()]); + + let cfg = ProviderConfig { + model_allowlist: allow, + ..ProviderConfig::default() + }; + + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + serialized.contains("model_allowlist"), + "expected model_allowlist in serialized output, got: {serialized}" + ); + + let parsed: ProviderConfig = + toml::from_str(&serialized).expect("deserialize ProviderConfig"); + assert_eq!(parsed.model_allowlist.get("anthropic").unwrap().len(), 2); + assert_eq!(parsed.model_allowlist.get("openai").unwrap()[0], "gpt-5.5"); + } + + #[test] + fn provider_model_allowlist_default_skipped_when_empty() { + let cfg = ProviderConfig::default(); + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + !serialized.contains("model_allowlist"), + "empty allowlist should be omitted from TOML output, got: {serialized}" + ); + } + + #[test] + fn provider_enabled_providers_round_trips_through_toml() { + let cfg = ProviderConfig { + enabled_providers: vec![ + "openai".to_string(), + "ollama-cloud".to_string(), + "opencode-go".to_string(), + ], + ..ProviderConfig::default() + }; + + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + serialized.contains("enabled_providers"), + "expected enabled_providers in serialized output, got: {serialized}" + ); + + let parsed: ProviderConfig = + toml::from_str(&serialized).expect("deserialize ProviderConfig"); + assert_eq!(parsed.enabled_providers, cfg.enabled_providers); + } + + #[test] + fn provider_enabled_providers_default_skipped_when_empty() { + let cfg = ProviderConfig::default(); + let serialized = toml::to_string(&cfg).expect("serialize ProviderConfig"); + assert!( + !serialized.contains("enabled_providers"), + "empty enabled providers should be omitted from TOML output, got: {serialized}" + ); + } +} diff --git a/crates/jcode-desktop/Cargo.toml b/crates/jcode-desktop/Cargo.toml index 2347cc53b..b518d8405 100644 --- a/crates/jcode-desktop/Cargo.toml +++ b/crates/jcode-desktop/Cargo.toml @@ -9,14 +9,14 @@ anyhow = "1" arboard = "3" base64 = "0.22" bytemuck = { version = "1", features = ["derive"] } -glyphon = "0.5" +glyphon = "0.6" image = { version = "0.25", default-features = false, features = ["png"] } libc = "0.2" pollster = "0.3" pulldown-cmark = "0.12" serde_json = "1" -wgpu = "0.19" -winit = "0.29" +wgpu = "22" +winit = "0.30" [target.'cfg(any(target_os = "macos", windows))'.dependencies] whoami = "1" diff --git a/crates/jcode-desktop/src/main.rs b/crates/jcode-desktop/src/main.rs index 74bbec082..423d77e07 100644 --- a/crates/jcode-desktop/src/main.rs +++ b/crates/jcode-desktop/src/main.rs @@ -13,8 +13,8 @@ use anyhow::{Context, Result}; use base64::Engine; use bytemuck::{Pod, Zeroable}; use glyphon::{ - Attrs, Buffer, Color as TextColor, Family, FontSystem, Metrics, Resolution, Shaping, - SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Wrap, + Attrs, Buffer, Cache, Color as TextColor, Family, FontSystem, Metrics, Resolution, Shaping, + SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap, }; use image::RgbaImage; use render_helpers::*; @@ -26,11 +26,12 @@ use single_session::{ }; use single_session_render::*; use wgpu::{CompositeAlphaMode, PresentMode, SurfaceError, TextureUsages}; +use winit::application::ApplicationHandler; use winit::dpi::{LogicalSize, PhysicalSize}; -use winit::event::{ElementState, Event, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}; -use winit::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy}; +use winit::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; use winit::keyboard::{Key, ModifiersState, NamedKey}; -use winit::window::{Fullscreen, Window, WindowBuilder}; +use winit::window::{Fullscreen, Window, WindowId}; use workspace::{InputMode, KeyInput, KeyOutcome, PanelSizePreset, Workspace}; use std::collections::hash_map::DefaultHasher; @@ -226,30 +227,25 @@ async fn run() -> Result<()> { "pid": std::process::id(), }), ); - let event_loop = EventLoopBuilder::::with_user_event() - .build() - .context("failed to create event loop")?; + #[cfg(target_os = "macos")] + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + let event_loop = { + let mut builder = EventLoop::::with_user_event(); + #[cfg(target_os = "macos")] + { + builder.with_activation_policy(ActivationPolicy::Regular); + builder.with_activate_ignoring_other_apps(true); + } + builder.build().context("failed to create event loop")? + }; let event_loop_proxy = event_loop.create_proxy(); startup_trace.mark("event loop created"); - let mut window_builder = WindowBuilder::new() - .with_title("Jcode Desktop") - .with_inner_size(LogicalSize::new( - DEFAULT_WINDOW_WIDTH, - DEFAULT_WINDOW_HEIGHT, - )); - - if fullscreen { - window_builder = window_builder.with_fullscreen(Some(Fullscreen::Borderless(None))); - } - let window: &'static Window = Box::leak(Box::new( - window_builder - .build(&event_loop) - .context("failed to create desktop window")?, - )); - startup_trace.mark("window created"); + let preferences_save_tx = spawn_desktop_preferences_saver(); + let (session_event_tx, session_event_rx) = mpsc::channel(); + spawn_session_event_forwarder(session_event_rx, event_loop_proxy.clone()); - let mut app = if desktop_mode == DesktopMode::WorkspacePrototype { + let initial_app = if desktop_mode == DesktopMode::WorkspacePrototype { let session_cards = load_session_cards_for_desktop(); let mut workspace = Workspace::from_session_cards(session_cards); if let Some(preferences) = load_desktop_preferences() { @@ -260,38 +256,135 @@ async fn run() -> Result<()> { initial_single_session_app(resume_session_id.as_deref()) }; startup_trace.mark("app state initialized"); - window.set_title(&app.status_title()); - let mut canvas = Canvas::new(window, startup_trace).await?; - startup_trace.mark("canvas ready"); - let mut modifiers = ModifiersState::empty(); - let mut cursor_position = winit::dpi::PhysicalPosition::new(0.0, 0.0); - let mut selecting_body = false; - let mut selecting_draft = false; - let mut scroll_accumulator = ScrollLineAccumulator::default(); - let mut scroll_metrics_cache = SingleSessionScrollMetricsCache::default(); - let mut hot_reloader = DesktopHotReloader::new(); - let preferences_save_tx = spawn_desktop_preferences_saver(); - let mut power_inhibitor = power_inhibit::PowerInhibitor::new(); - let (session_event_tx, session_event_rx) = mpsc::channel(); - spawn_session_event_forwarder(session_event_rx, event_loop_proxy.clone()); - let mut recovery_scan_pending = app.is_single_session(); - let mut first_frame_presented = false; - let mut interaction_latency = DesktopInteractionLatencyProfiler::new(); - let mut no_paint_watchdog = DesktopNoPaintWatchdog::new(); - let mut last_backend_redraw_request: Option = None; - let mut pending_backend_redraw_since: Option = None; - - event_loop.run(move |event, target| { + let recovery_scan_pending = initial_app.is_single_session(); + + let mut handler = DesktopHandler { + app: initial_app, + window: None, + canvas: None, + startup_trace, + startup_benchmark, + fullscreen, + modifiers: ModifiersState::empty(), + cursor_position: winit::dpi::PhysicalPosition::new(0.0, 0.0), + selecting_body: false, + selecting_draft: false, + scroll_accumulator: ScrollLineAccumulator::default(), + scroll_metrics_cache: SingleSessionScrollMetricsCache::default(), + hot_reloader: DesktopHotReloader::new(), + preferences_save_tx, + power_inhibitor: power_inhibit::PowerInhibitor::new(), + session_event_tx, + event_loop_proxy, + recovery_scan_pending, + first_frame_presented: false, + interaction_latency: DesktopInteractionLatencyProfiler::new(), + no_paint_watchdog: DesktopNoPaintWatchdog::new(), + last_backend_redraw_request: None, + pending_backend_redraw_since: None, + exit_requested: false, + }; + + event_loop + .run_app(&mut handler) + .context("event loop terminated with error")?; + + Ok(()) +} + +struct DesktopHandler { + app: DesktopApp, + window: Option<&'static Window>, + canvas: Option>, + startup_trace: DesktopStartupTrace, + startup_benchmark: bool, + fullscreen: bool, + modifiers: ModifiersState, + cursor_position: winit::dpi::PhysicalPosition, + selecting_body: bool, + selecting_draft: bool, + scroll_accumulator: ScrollLineAccumulator, + scroll_metrics_cache: SingleSessionScrollMetricsCache, + hot_reloader: DesktopHotReloader, + preferences_save_tx: Option>, + power_inhibitor: power_inhibit::PowerInhibitor, + session_event_tx: mpsc::Sender, + event_loop_proxy: EventLoopProxy, + recovery_scan_pending: bool, + first_frame_presented: bool, + interaction_latency: DesktopInteractionLatencyProfiler, + no_paint_watchdog: DesktopNoPaintWatchdog, + last_backend_redraw_request: Option, + pending_backend_redraw_since: Option, + exit_requested: bool, +} + +impl DesktopHandler { + fn ensure_window_and_canvas(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let mut attrs = Window::default_attributes() + .with_title("Jcode Desktop") + .with_inner_size(LogicalSize::new( + DEFAULT_WINDOW_WIDTH, + DEFAULT_WINDOW_HEIGHT, + )); + if self.fullscreen { + attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None))); + } + let window = match event_loop.create_window(attrs) { + Ok(window) => window, + Err(error) => { + eprintln!("jcode-desktop: failed to create desktop window: {error:#}"); + event_loop.exit(); + return; + } + }; + let window: &'static Window = Box::leak(Box::new(window)); + self.startup_trace.mark("window created"); + window.set_title(&self.app.status_title()); + let canvas = match pollster::block_on(Canvas::new(window, self.startup_trace)) { + Ok(canvas) => canvas, + Err(error) => { + eprintln!("jcode-desktop: failed to create canvas: {error:#}"); + event_loop.exit(); + return; + } + }; + self.startup_trace.mark("canvas ready"); + self.window = Some(window); + self.canvas = Some(canvas); + window.request_redraw(); + } + + fn maybe_exit(&mut self, event_loop: &ActiveEventLoop) { + if self.exit_requested { + event_loop.exit(); + } + } +} + +impl ApplicationHandler for DesktopHandler { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.ensure_window_and_canvas(event_loop); + } + + fn new_events(&mut self, event_loop: &ActiveEventLoop, _cause: winit::event::StartCause) { + let Some(window) = self.window else { + return; + }; let event_loop_now = Instant::now(); - let has_background_work = app.has_background_work(); - power_inhibitor.set_active(has_background_work); - let default_wake = if has_background_work || app.has_frame_animation() { + let has_background_work = self.app.has_background_work(); + self.power_inhibitor.set_active(has_background_work); + let default_wake = if has_background_work || self.app.has_frame_animation() { Some(event_loop_now + BACKGROUND_POLL_INTERVAL) } else { None }; - let backend_wake = pending_backend_redraw_since - .and_then(|_| last_backend_redraw_request) + let backend_wake = self + .pending_backend_redraw_since + .and_then(|_| self.last_backend_redraw_request) .map(|last| last + BACKEND_REDRAW_FRAME_INTERVAL); let wake = match (default_wake, backend_wake) { (Some(default_wake), Some(backend_wake)) => Some(default_wake.min(backend_wake)), @@ -299,24 +392,24 @@ async fn run() -> Result<()> { (None, None) => None, }; if let Some(wake) = wake { - target.set_control_flow(ControlFlow::WaitUntil(wake)); + event_loop.set_control_flow(ControlFlow::WaitUntil(wake)); } else { - target.set_control_flow(ControlFlow::Wait); + event_loop.set_control_flow(ControlFlow::Wait); } - let pending_interaction_kind = interaction_latency.pending_kind(); - let frame_animation_active = app.has_frame_animation(); - let pending_backend_redraw = pending_backend_redraw_since.is_some(); - let no_paint_active = !first_frame_presented + let pending_interaction_kind = self.interaction_latency.pending_kind(); + let frame_animation_active = self.app.has_frame_animation(); + let pending_backend_redraw = self.pending_backend_redraw_since.is_some(); + let no_paint_active = !self.first_frame_presented || has_background_work || frame_animation_active || pending_backend_redraw || pending_interaction_kind.is_some(); - if no_paint_watchdog.observe_active_tick( + if self.no_paint_watchdog.observe_active_tick( event_loop_now, NoPaintWatchdogContext { active: no_paint_active, - mode: app.mode(), + mode: self.app.mode(), has_background_work, frame_animation_active, pending_backend_redraw, @@ -325,427 +418,457 @@ async fn run() -> Result<()> { ) { window.request_redraw(); } + } + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + let Some(window) = self.window else { + return; + }; + if window_id != window.id() { + return; + } + let Some(canvas) = self.canvas.as_mut() else { + return; + }; match event { - Event::WindowEvent { event, window_id } if window_id == window.id() => match event { - WindowEvent::CloseRequested => target.exit(), - WindowEvent::Resized(size) => { - canvas.resize(size); - scroll_metrics_cache.clear(); - window.request_redraw(); - } - WindowEvent::ScaleFactorChanged { .. } => { - canvas.resize(window.inner_size()); - scroll_metrics_cache.clear(); - window.request_redraw(); - } - WindowEvent::ModifiersChanged(new_modifiers) => { - modifiers = new_modifiers.state(); - } - WindowEvent::MouseWheel { delta, phase, .. } => { - let size = window.inner_size(); - let now = Instant::now(); - let previous_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - let mut should_redraw = false; - if !app.is_single_session() { - scroll_accumulator.reset(); - scroll_metrics_cache.clear(); - } else if let Some(lines) = scroll_accumulator.scroll_lines(delta, now) { - should_redraw |= - app.scroll_single_session_body(lines, size, &mut scroll_metrics_cache); - } - if matches!(phase, TouchPhase::Cancelled) { - scroll_accumulator.reset(); - } - let next_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), + WindowEvent::CloseRequested => { + event_loop.exit(); + } + WindowEvent::Resized(size) => { + canvas.resize(size); + self.scroll_metrics_cache.clear(); + window.request_redraw(); + } + WindowEvent::ScaleFactorChanged { .. } => { + canvas.resize(window.inner_size()); + self.scroll_metrics_cache.clear(); + window.request_redraw(); + } + WindowEvent::ModifiersChanged(new_modifiers) => { + self.modifiers = new_modifiers.state(); + } + WindowEvent::MouseWheel { delta, phase, .. } => { + let size = window.inner_size(); + let now = Instant::now(); + let previous_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + let mut should_redraw = false; + if !self.app.is_single_session() { + self.scroll_accumulator.reset(); + self.scroll_metrics_cache.clear(); + } else if let Some(lines) = self.scroll_accumulator.scroll_lines(delta, now) { + should_redraw |= self.app.scroll_single_session_body( + lines, size, - &mut scroll_metrics_cache, + &mut self.scroll_metrics_cache, ); - should_redraw |= (next_smooth_scroll - previous_smooth_scroll).abs() - >= SCROLL_FRACTIONAL_EPSILON; - if should_redraw { - interaction_latency.mark("mouse_wheel", now); - window.request_redraw(); - } } - WindowEvent::CursorMoved { position, .. } => { - let cursor_started = Instant::now(); - cursor_position = position; - if selecting_draft - && app.update_single_session_draft_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, - window.inner_size(), - ) - { - interaction_latency.mark("draft_selection_drag", cursor_started); - window.request_redraw(); - } else if selecting_body - && app.update_single_session_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, - window.inner_size(), - ) - { - interaction_latency.mark("body_selection_drag", cursor_started); - window.request_redraw(); - } + if matches!(phase, TouchPhase::Cancelled) { + self.scroll_accumulator.reset(); + } + let next_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + should_redraw |= (next_smooth_scroll - previous_smooth_scroll).abs() + >= SCROLL_FRACTIONAL_EPSILON; + if should_redraw { + self.interaction_latency.mark("mouse_wheel", now); + window.request_redraw(); } - WindowEvent::MouseInput { - state, - button: MouseButton::Left, - .. - } => { - let mouse_started = Instant::now(); - match state { - ElementState::Pressed => { - if app.begin_single_session_draft_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + } + WindowEvent::CursorMoved { position, .. } => { + let cursor_started = Instant::now(); + self.cursor_position = position; + if self.selecting_draft + && self.app.update_single_session_draft_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, + window.inner_size(), + ) + { + self.interaction_latency + .mark("draft_selection_drag", cursor_started); + window.request_redraw(); + } else if self.selecting_body + && self.app.update_single_session_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, + window.inner_size(), + ) + { + self.interaction_latency + .mark("body_selection_drag", cursor_started); + window.request_redraw(); + } + } + WindowEvent::MouseInput { + state, + button: MouseButton::Left, + .. + } => { + let mouse_started = Instant::now(); + match state { + ElementState::Pressed => { + if self.app.begin_single_session_draft_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ) { - selecting_body = false; - selecting_draft = true; - window.set_title(&app.status_title()); - interaction_latency.mark("mouse_press", mouse_started); + self.selecting_body = false; + self.selecting_draft = true; + window.set_title(&self.app.status_title()); + self.interaction_latency.mark("mouse_press", mouse_started); window.request_redraw(); return; } - selecting_draft = false; - selecting_body = app.begin_single_session_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + self.selecting_draft = false; + self.selecting_body = self.app.begin_single_session_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ); - if selecting_body { - interaction_latency.mark("mouse_press", mouse_started); + if self.selecting_body { + self.interaction_latency.mark("mouse_press", mouse_started); window.request_redraw(); } } ElementState::Released => { - if selecting_draft { - app.update_single_session_draft_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + if self.selecting_draft { + self.app.update_single_session_draft_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ); - selecting_draft = false; - let selected = app.selected_single_session_draft_text(); + self.selecting_draft = false; + let selected = self.app.selected_single_session_draft_text(); if let Some(text) = selected { - copy_text_to_clipboard(&text, "copied input selection", &mut app); + copy_text_to_clipboard( + &text, + "copied input selection", + &mut self.app, + ); } - window.set_title(&app.status_title()); - interaction_latency.mark("mouse_release", mouse_started); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("mouse_release", mouse_started); window.request_redraw(); - } else if selecting_body { - app.update_single_session_selection_at( - cursor_position.x as f32, - cursor_position.y as f32, + } else if self.selecting_body { + self.app.update_single_session_selection_at( + self.cursor_position.x as f32, + self.cursor_position.y as f32, window.inner_size(), ); - selecting_body = false; - let selected = app.selected_single_session_text(window.inner_size()); + self.selecting_body = false; + let selected = + self.app.selected_single_session_text(window.inner_size()); if let Some(text) = selected { - copy_text_to_clipboard(&text, "copied selection", &mut app); + copy_text_to_clipboard(&text, "copied selection", &mut self.app); } - window.set_title(&app.status_title()); - interaction_latency.mark("mouse_release", mouse_started); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("mouse_release", mouse_started); window.request_redraw(); } } - } } - WindowEvent::KeyboardInput { event, .. } - if event.state == ElementState::Pressed => - { - let keyboard_started = Instant::now(); - let size = window.inner_size(); - let had_smooth_scroll = app - .single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ) - .abs() - >= SCROLL_FRACTIONAL_EPSILON; - scroll_accumulator.reset(); - if had_smooth_scroll { + } + WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { + let keyboard_started = Instant::now(); + let size = window.inner_size(); + let had_smooth_scroll = self + .app + .single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ) + .abs() + >= SCROLL_FRACTIONAL_EPSILON; + self.scroll_accumulator.reset(); + if had_smooth_scroll { + window.request_redraw(); + } + let key_input = to_key_input(&event.logical_key, self.modifiers); + let key_debug = format!("{key_input:?}"); + self.interaction_latency + .mark("keyboard_input", keyboard_started); + if key_input == KeyInput::RefreshSessions && self.app.is_workspace() { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + Duration::ZERO, + ); + window.request_redraw(); + return; + } + + match self.app.handle_key(key_input) { + KeyOutcome::Exit => event_loop.exit(), + KeyOutcome::Redraw => { + if let DesktopApp::Workspace(workspace) = &self.app { + queue_desktop_preferences_save(workspace, &self.preferences_save_tx); + } + window.set_title(&self.app.status_title()); window.request_redraw(); } - let key_input = to_key_input(&event.logical_key, modifiers); - let key_debug = format!("{key_input:?}"); - interaction_latency.mark("keyboard_input", keyboard_started); - if key_input == KeyInput::RefreshSessions && app.is_workspace() { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - Duration::ZERO, - ); - window.request_redraw(); - return; + KeyOutcome::OpenSession { session_id, title } => { + if let DesktopApp::Workspace(workspace) = &self.app { + queue_desktop_preferences_save(workspace, &self.preferences_save_tx); + } + if let Err(error) = + session_launch::launch_validated_resume_session(&session_id, &title) + { + eprintln!( + "jcode-desktop: failed to open session {session_id}: {error:#}" + ); + } } - - match app.handle_key(key_input) { - KeyOutcome::Exit => target.exit(), - KeyOutcome::Redraw => { - if let DesktopApp::Workspace(workspace) = &app { - queue_desktop_preferences_save(workspace, &preferences_save_tx); - } - window.set_title(&app.status_title()); + KeyOutcome::SpawnSession => { + if let DesktopApp::SingleSession(single_session_app) = &mut self.app { + single_session_app.reset_fresh_session(); + window.set_title(&self.app.status_title()); window.request_redraw(); + return; } - KeyOutcome::OpenSession { session_id, title } => { - if let DesktopApp::Workspace(workspace) = &app { - queue_desktop_preferences_save(workspace, &preferences_save_tx); - } - if let Err(error) = - session_launch::launch_validated_resume_session(&session_id, &title) - { - eprintln!( - "jcode-desktop: failed to open session {session_id}: {error:#}" - ); - } - } - KeyOutcome::SpawnSession => { - if let DesktopApp::SingleSession(app) = &mut app { - app.reset_fresh_session(); - window.set_title(&app.status_title()); - window.request_redraw(); - return; - } - if let Err(error) = session_launch::launch_new_session() { - eprintln!("jcode-desktop: failed to spawn session: {error:#}"); - } else { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - SESSION_SPAWN_REFRESH_DELAY, - ); - window.request_redraw(); - } - } - KeyOutcome::SendDraft { - session_id, - title, - message, - images, - } => { - if app.is_single_session() { - match session_launch::spawn_message_to_session( - session_id.clone(), - message, - images, - session_event_tx.clone(), - ) { - Ok(handle) => app.set_single_session_handle(handle), - Err(error) => apply_single_session_error(&mut app, error), - } - window.set_title(&app.status_title()); - window.request_redraw(); - } else if !images.is_empty() { - match session_launch::spawn_message_to_session( - session_id.clone(), - message, - images, - session_event_tx.clone(), - ) { - Ok(_handle) => { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - SESSION_SPAWN_REFRESH_DELAY, - ); - window.request_redraw(); - } - Err(error) => eprintln!( - "jcode-desktop: failed to send image draft to {session_id}: {error:#}" - ), - } - } else if let Err(error) = session_launch::send_message_to_session( - &session_id, - &title, - &message, - ) { - eprintln!( - "jcode-desktop: failed to send draft to {session_id}: {error:#}" - ); - } else { - spawn_session_cards_load( - DesktopSessionCardsPurpose::WorkspaceRefresh, - event_loop_proxy.clone(), - SESSION_SPAWN_REFRESH_DELAY, - ); - window.request_redraw(); - } + if let Err(error) = session_launch::launch_new_session() { + eprintln!("jcode-desktop: failed to spawn session: {error:#}"); + } else { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + SESSION_SPAWN_REFRESH_DELAY, + ); + window.request_redraw(); } - KeyOutcome::StartFreshSession { message, images } => { - match session_launch::spawn_fresh_server_session( + } + KeyOutcome::SendDraft { + session_id, + title, + message, + images, + } => { + if self.app.is_single_session() { + match session_launch::spawn_message_to_session( + session_id.clone(), message, images, - session_event_tx.clone(), + self.session_event_tx.clone(), ) { - Ok(handle) => app.set_single_session_handle(handle), - Err(error) => apply_single_session_error(&mut app, error), + Ok(handle) => self.app.set_single_session_handle(handle), + Err(error) => apply_single_session_error(&mut self.app, error), } - window.set_title(&app.status_title()); - window.request_redraw(); - } - KeyOutcome::CancelGeneration => { - app.cancel_single_session_generation(); - window.set_title(&app.status_title()); - window.request_redraw(); - } - KeyOutcome::CopyLatestResponse(text) => { - copy_text_to_clipboard(&text, "copied latest response", &mut app); - window.set_title(&app.status_title()); - window.request_redraw(); - } - KeyOutcome::CutDraftToClipboard(text) => { - copy_text_to_clipboard(&text, "cut input line", &mut app); - window.set_title(&app.status_title()); + window.set_title(&self.app.status_title()); window.request_redraw(); - } - KeyOutcome::CycleModel(direction) => { - if let Err(error) = session_launch::spawn_cycle_model( - direction, - app.single_session_live_id(), - session_event_tx.clone(), + } else if !images.is_empty() { + match session_launch::spawn_message_to_session( + session_id.clone(), + message, + images, + self.session_event_tx.clone(), ) { - apply_single_session_error(&mut app, error); - } else { - app.apply_session_event( - session_launch::DesktopSessionEvent::Status( - "switching model".to_string(), - ), - ); + Ok(_handle) => { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + SESSION_SPAWN_REFRESH_DELAY, + ); + window.request_redraw(); + } + Err(error) => eprintln!( + "jcode-desktop: failed to send image draft to {session_id}: {error:#}" + ), } - window.set_title(&app.status_title()); + } else if let Err(error) = + session_launch::send_message_to_session(&session_id, &title, &message) + { + eprintln!( + "jcode-desktop: failed to send draft to {session_id}: {error:#}" + ); + } else { + spawn_session_cards_load( + DesktopSessionCardsPurpose::WorkspaceRefresh, + self.event_loop_proxy.clone(), + SESSION_SPAWN_REFRESH_DELAY, + ); window.request_redraw(); } - KeyOutcome::CycleReasoningEffort(direction) => { - if let Err(error) = session_launch::spawn_cycle_reasoning_effort( - direction, - app.single_session_live_id(), - session_event_tx.clone(), - ) { - apply_single_session_error(&mut app, error); - } else { - app.apply_session_event( - session_launch::DesktopSessionEvent::Status( - "switching reasoning effort".to_string(), - ), - ); - } - window.set_title(&app.status_title()); - window.request_redraw(); + } + KeyOutcome::StartFreshSession { message, images } => { + match session_launch::spawn_fresh_server_session( + message, + images, + self.session_event_tx.clone(), + ) { + Ok(handle) => self.app.set_single_session_handle(handle), + Err(error) => apply_single_session_error(&mut self.app, error), } - KeyOutcome::LoadModelCatalog => { - if let Err(error) = session_launch::spawn_load_model_catalog( - app.single_session_live_id(), - session_event_tx.clone(), - ) { - apply_single_session_error(&mut app, error); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CancelGeneration => { + self.app.cancel_single_session_generation(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CopyLatestResponse(text) => { + copy_text_to_clipboard(&text, "copied latest response", &mut self.app); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CutDraftToClipboard(text) => { + copy_text_to_clipboard(&text, "cut input line", &mut self.app); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CycleModel(direction) => { + if let Err(error) = session_launch::spawn_cycle_model( + direction, + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); + } else { + self.app.apply_session_event( + session_launch::DesktopSessionEvent::Status( + "switching model".to_string(), + ), + ); } - KeyOutcome::LoadSessionSwitcher => { - spawn_session_cards_load( - DesktopSessionCardsPurpose::SingleSessionSwitcher, - event_loop_proxy.clone(), - Duration::ZERO, + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::CycleReasoningEffort(direction) => { + if let Err(error) = session_launch::spawn_cycle_reasoning_effort( + direction, + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); + } else { + self.app.apply_session_event( + session_launch::DesktopSessionEvent::Status( + "switching reasoning effort".to_string(), + ), ); - window.set_title(&app.status_title()); - window.request_redraw(); } - KeyOutcome::RestoreCrashedSessions => { - spawn_restore_crashed_sessions(event_loop_proxy.clone()); - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::LoadModelCatalog => { + if let Err(error) = session_launch::spawn_load_model_catalog( + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); } - KeyOutcome::SetModel(model) => { - if let Err(error) = session_launch::spawn_set_model( - model, - app.single_session_live_id(), - session_event_tx.clone(), - ) { - apply_single_session_error(&mut app, error); - } else { - app.apply_session_event( - session_launch::DesktopSessionEvent::Status( - "switching model".to_string(), - ), - ); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::LoadSessionSwitcher => { + spawn_session_cards_load( + DesktopSessionCardsPurpose::SingleSessionSwitcher, + self.event_loop_proxy.clone(), + Duration::ZERO, + ); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::RestoreCrashedSessions => { + spawn_restore_crashed_sessions(self.event_loop_proxy.clone()); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::SetModel(model) => { + if let Err(error) = session_launch::spawn_set_model( + model, + self.app.single_session_live_id(), + self.session_event_tx.clone(), + ) { + apply_single_session_error(&mut self.app, error); + } else { + self.app.apply_session_event( + session_launch::DesktopSessionEvent::Status( + "switching model".to_string(), + ), + ); } - KeyOutcome::SendStdinResponse { request_id, input } => { - if let Err(error) = app.send_single_session_stdin_response(request_id, input) - { - apply_single_session_error(&mut app, error); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::SendStdinResponse { request_id, input } => { + if let Err(error) = self + .app + .send_single_session_stdin_response(request_id, input) + { + apply_single_session_error(&mut self.app, error); } - KeyOutcome::AttachClipboardImage => { - match clipboard_image_png_base64() { - Ok((media_type, base64_data)) => { - app.attach_clipboard_image(media_type, base64_data); - } - Err(error) => apply_single_session_error(&mut app, error), + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::AttachClipboardImage => { + match clipboard_image_png_base64() { + Ok((media_type, base64_data)) => { + self.app.attach_clipboard_image(media_type, base64_data); } - window.set_title(&app.status_title()); - window.request_redraw(); + Err(error) => apply_single_session_error(&mut self.app, error), } - KeyOutcome::PasteText => { - if let Err(error) = paste_clipboard_into_app(&mut app) { - apply_single_session_error(&mut app, error); - } - window.set_title(&app.status_title()); - window.request_redraw(); + window.set_title(&self.app.status_title()); + window.request_redraw(); + } + KeyOutcome::PasteText => { + if let Err(error) = paste_clipboard_into_app(&mut self.app) { + apply_single_session_error(&mut self.app, error); } - KeyOutcome::None => {} + window.set_title(&self.app.status_title()); + window.request_redraw(); } - log_desktop_slow_interaction( - "keyboard_input", - keyboard_started.elapsed(), - serde_json::json!({ "key": key_debug }), - ); + KeyOutcome::None => {} } - WindowEvent::RedrawRequested => { - let smooth_scroll_lines = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - window.inner_size(), - &mut scroll_metrics_cache, - ); - match canvas.render( - &app, - window.current_monitor().map(|monitor| monitor.size()), - smooth_scroll_lines, - ) { + log_desktop_slow_interaction( + "keyboard_input", + keyboard_started.elapsed(), + serde_json::json!({ "key": key_debug }), + ); + } + WindowEvent::RedrawRequested => { + let smooth_scroll_lines = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + window.inner_size(), + &mut self.scroll_metrics_cache, + ); + match canvas.render( + &self.app, + window.current_monitor().map(|monitor| monitor.size()), + smooth_scroll_lines, + ) { Ok(frame) => { - no_paint_watchdog.observe_presented(Instant::now(), &frame); - interaction_latency.observe_presented(&frame); - if !first_frame_presented { - first_frame_presented = true; - startup_trace.mark("first frame presented"); - if startup_benchmark { - target.exit(); + self.no_paint_watchdog + .observe_presented(Instant::now(), &frame); + self.interaction_latency.observe_presented(&frame); + if !self.first_frame_presented { + self.first_frame_presented = true; + self.startup_trace.mark("first frame presented"); + if self.startup_benchmark { + event_loop.exit(); return; } - if recovery_scan_pending { - recovery_scan_pending = false; + if self.recovery_scan_pending { + self.recovery_scan_pending = false; spawn_recovery_session_count_scan( - event_loop_proxy.clone(), - startup_trace, + self.event_loop_proxy.clone(), + self.startup_trace, ); } } @@ -757,59 +880,68 @@ async fn run() -> Result<()> { canvas.resize(window.inner_size()); window.request_redraw(); } - Err(SurfaceError::OutOfMemory) => target.exit(), + Err(SurfaceError::OutOfMemory) => event_loop.exit(), Err(SurfaceError::Timeout) => { window.request_redraw(); } - } } - _ => {} - }, - Event::UserEvent(DesktopUserEvent::RecoveryCount(recovery_count)) => { - if let DesktopApp::SingleSession(single_session) = &mut app { + } + _ => {} + } + } + + fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: DesktopUserEvent) { + let Some(window) = self.window else { + return; + }; + match event { + DesktopUserEvent::RecoveryCount(recovery_count) => { + if let DesktopApp::SingleSession(single_session) = &mut self.app { single_session.set_recovery_session_count(recovery_count); - window.set_title(&app.status_title()); - interaction_latency.mark("recovery_count", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("recovery_count", Instant::now()); window.request_redraw(); } } - Event::UserEvent(DesktopUserEvent::SessionCardsLoaded { + DesktopUserEvent::SessionCardsLoaded { purpose, cards, loaded_in, - }) => { + } => { let card_count = cards.len(); let mut applied = false; match purpose { DesktopSessionCardsPurpose::WorkspaceRefresh => { - if let DesktopApp::Workspace(workspace) = &mut app { + if let DesktopApp::Workspace(workspace) = &mut self.app { workspace.replace_session_cards(cards); - queue_desktop_preferences_save(workspace, &preferences_save_tx); + queue_desktop_preferences_save(workspace, &self.preferences_save_tx); applied = true; } } DesktopSessionCardsPurpose::SingleSessionSwitcher => { - if app.is_single_session() { - app.apply_single_session_switcher_cards(cards); + if self.app.is_single_session() { + self.app.apply_single_session_switcher_cards(cards); applied = true; } } } log_desktop_session_cards_load_profile(purpose, loaded_in, card_count, applied); if applied { - window.set_title(&app.status_title()); - interaction_latency.mark("session_cards_load", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("session_cards_load", Instant::now()); window.request_redraw(); } } - Event::UserEvent(DesktopUserEvent::SessionCardLoaded { + DesktopUserEvent::SessionCardLoaded { session_id, card, loaded_in, - }) => { + } => { let card_found = card.is_some(); let mut applied = false; - if let DesktopApp::SingleSession(single_session) = &mut app + if let DesktopApp::SingleSession(single_session) = &mut self.app && single_session.live_session_id.as_deref() == Some(session_id.as_str()) && let Some(card) = card { @@ -823,16 +955,17 @@ async fn run() -> Result<()> { applied, ); if applied { - window.set_title(&app.status_title()); - interaction_latency.mark("session_card_refresh", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("session_card_refresh", Instant::now()); window.request_redraw(); } } - Event::UserEvent(DesktopUserEvent::CrashedSessionsRestoreFinished { + DesktopUserEvent::CrashedSessionsRestoreFinished { restored, errors, elapsed, - }) => { + } => { log_desktop_crashed_sessions_restore_profile(restored, errors.len(), elapsed); if restored == 0 { let message = if errors.is_empty() { @@ -840,24 +973,28 @@ async fn run() -> Result<()> { } else { format!("failed to restore crashed sessions: {}", errors.join("; ")) }; - apply_single_session_error(&mut app, anyhow::anyhow!(message)); - } else if let DesktopApp::SingleSession(single_session) = &mut app { + apply_single_session_error(&mut self.app, anyhow::anyhow!(message)); + } else if let DesktopApp::SingleSession(single_session) = &mut self.app { single_session.set_recovery_session_count(0); - single_session.apply_session_event(session_launch::DesktopSessionEvent::Status( - format!("restored {restored} crashed session(s)"), - )); + single_session.apply_session_event( + session_launch::DesktopSessionEvent::Status(format!( + "restored {restored} crashed session(s)" + )), + ); } - window.set_title(&app.status_title()); - interaction_latency.mark("restore_crashed_sessions", Instant::now()); + window.set_title(&self.app.status_title()); + self.interaction_latency + .mark("restore_crashed_sessions", Instant::now()); window.request_redraw(); } - Event::UserEvent(DesktopUserEvent::SessionEvents(batch)) => { + DesktopUserEvent::SessionEvents(batch) => { let ui_received_at = Instant::now(); let accumulated_for = batch.accumulated_for(); let raw_event_count = batch.raw_event_count; let raw_payload_bytes = batch.raw_payload_bytes; let forwarded_at = batch.forwarded_at; - let apply_stats = apply_desktop_session_event_batch_with_stats(&mut app, batch.events); + let apply_stats = + apply_desktop_session_event_batch_with_stats(&mut self.app, batch.events); let ui_queue_delay = ui_received_at.saturating_duration_since(forwarded_at); let mut redraw_requested = false; let mut redraw_deferred = false; @@ -865,43 +1002,49 @@ async fn run() -> Result<()> { if apply_stats.visible_changed { let now = Instant::now(); if apply_stats.session_card_refresh_requested - && let Some(session_id) = app.single_session_live_id() + && let Some(session_id) = self.app.single_session_live_id() { - spawn_single_session_card_refresh(session_id, event_loop_proxy.clone()); + spawn_single_session_card_refresh( + session_id, + self.event_loop_proxy.clone(), + ); session_card_refresh_spawned = true; } - if let Some((message, images)) = app.take_next_queued_single_session_draft() { - let result = if let Some(session_id) = app.single_session_live_id() { + if let Some((message, images)) = + self.app.take_next_queued_single_session_draft() + { + let result = if let Some(session_id) = self.app.single_session_live_id() { session_launch::spawn_message_to_session( session_id, message, images, - session_event_tx.clone(), + self.session_event_tx.clone(), ) } else { session_launch::spawn_fresh_server_session( message, images, - session_event_tx.clone(), + self.session_event_tx.clone(), ) }; match result { - Ok(handle) => app.set_single_session_handle(handle), - Err(error) => apply_single_session_error(&mut app, error), + Ok(handle) => self.app.set_single_session_handle(handle), + Err(error) => apply_single_session_error(&mut self.app, error), } } - window.set_title(&app.status_title()); - let redraw_due = last_backend_redraw_request.is_none_or(|last| { + window.set_title(&self.app.status_title()); + let redraw_due = self.last_backend_redraw_request.is_none_or(|last| { now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL }); if redraw_due { - let first_pending = pending_backend_redraw_since.take().unwrap_or(now); - interaction_latency.mark("backend_events", first_pending); - last_backend_redraw_request = Some(now); + let first_pending = self.pending_backend_redraw_since.take().unwrap_or(now); + self.interaction_latency + .mark("backend_events", first_pending); + self.last_backend_redraw_request = Some(now); window.request_redraw(); redraw_requested = true; } else { - pending_backend_redraw_since.get_or_insert(now); + self.pending_backend_redraw_since.get_or_insert(now); redraw_deferred = true; } } @@ -916,69 +1059,76 @@ async fn run() -> Result<()> { session_card_refresh_spawned, ); } - Event::AboutToWait => { - if app.is_single_session() { - let about_to_wait_started = Instant::now(); - let size = window.inner_size(); - let previous_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - let frame = scroll_accumulator.frame(Instant::now()); - if let Some(lines) = frame.scroll_lines - && !app.scroll_single_session_body(lines, size, &mut scroll_metrics_cache) - { - scroll_accumulator.stop(); - } - let next_smooth_scroll = app.single_session_smooth_scroll_lines( - scroll_accumulator.pending_lines(), - size, - &mut scroll_metrics_cache, - ); - if frame.active - || (next_smooth_scroll - previous_smooth_scroll).abs() - >= SCROLL_FRACTIONAL_EPSILON - { - interaction_latency.mark("scroll_momentum", about_to_wait_started); - window.request_redraw(); - } - } else if scroll_accumulator.is_active() { - scroll_accumulator.reset(); - scroll_metrics_cache.clear(); - } - if let Some(first_pending_backend_redraw) = pending_backend_redraw_since { - let now = Instant::now(); - if last_backend_redraw_request.is_none_or(|last| { - now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL - }) { - pending_backend_redraw_since = None; - interaction_latency.mark("backend_events", first_pending_backend_redraw); - last_backend_redraw_request = Some(now); - window.request_redraw(); - } - } - if let Some(relaunch) = hot_reloader.poll() { - if let Err(error) = relaunch.spawn() { - eprintln!("jcode-desktop: failed to hot reload desktop: {error:#}"); - } else { - target.exit(); - return; - } - } + } + } - if canvas.needs_initial_frame { - canvas.needs_initial_frame = false; - window.request_redraw(); - } else if app.has_frame_animation() { - window.request_redraw(); - } + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(window) = self.window else { + return; + }; + if self.app.is_single_session() { + let about_to_wait_started = Instant::now(); + let size = window.inner_size(); + let previous_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + let frame = self.scroll_accumulator.frame(Instant::now()); + if let Some(lines) = frame.scroll_lines + && !self + .app + .scroll_single_session_body(lines, size, &mut self.scroll_metrics_cache) + { + self.scroll_accumulator.stop(); + } + let next_smooth_scroll = self.app.single_session_smooth_scroll_lines( + self.scroll_accumulator.pending_lines(), + size, + &mut self.scroll_metrics_cache, + ); + if frame.active + || (next_smooth_scroll - previous_smooth_scroll).abs() >= SCROLL_FRACTIONAL_EPSILON + { + self.interaction_latency + .mark("scroll_momentum", about_to_wait_started); + window.request_redraw(); + } + } else if self.scroll_accumulator.is_active() { + self.scroll_accumulator.reset(); + self.scroll_metrics_cache.clear(); + } + if let Some(first_pending_backend_redraw) = self.pending_backend_redraw_since { + let now = Instant::now(); + if self.last_backend_redraw_request.is_none_or(|last| { + now.saturating_duration_since(last) >= BACKEND_REDRAW_FRAME_INTERVAL + }) { + self.pending_backend_redraw_since = None; + self.interaction_latency + .mark("backend_events", first_pending_backend_redraw); + self.last_backend_redraw_request = Some(now); + window.request_redraw(); + } + } + if let Some(relaunch) = self.hot_reloader.poll() { + if let Err(error) = relaunch.spawn() { + eprintln!("jcode-desktop: failed to hot reload desktop: {error:#}"); + } else { + event_loop.exit(); + return; } - _ => {} } - })?; - Ok(()) + if let Some(canvas) = self.canvas.as_mut() { + if canvas.needs_initial_frame { + canvas.needs_initial_frame = false; + window.request_redraw(); + } else if self.app.has_frame_animation() { + window.request_redraw(); + } + } + self.maybe_exit(event_loop); + } } fn load_session_cards_for_desktop() -> Vec { @@ -1308,6 +1458,7 @@ async fn render_hero_frame_to_image( label: Some("jcode-desktop-hero-capture-device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), }, None, ) @@ -1331,6 +1482,7 @@ async fn render_hero_frame_to_image( module: &shader, entry_point: "vs_main", buffers: &[Vertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, @@ -1340,6 +1492,7 @@ async fn render_hero_frame_to_image( blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], + compilation_options: wgpu::PipelineCompilationOptions::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, @@ -1353,6 +1506,7 @@ async fn render_hero_frame_to_image( depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, + cache: None, }); let texture = device.create_texture(&wgpu::TextureDescriptor { @@ -1373,7 +1527,9 @@ async fn render_hero_frame_to_image( let mut font_system = create_desktop_font_system(); let mut swash_cache = SwashCache::new(); - let mut text_atlas = TextAtlas::new(&device, &queue, format); + let glyphon_cache = Cache::new(&device); + let mut text_viewport = Viewport::new(&device, &glyphon_cache); + let mut text_atlas = TextAtlas::new(&device, &queue, &glyphon_cache, format); let mut text_renderer = TextRenderer::new( &mut text_atlas, &device, @@ -1404,16 +1560,20 @@ async fn render_hero_frame_to_image( ) }; if !text_areas.is_empty() { + text_viewport.update( + &queue, + Resolution { + width: size.width, + height: size.height, + }, + ); text_renderer .prepare( &device, &queue, &mut font_system, &mut text_atlas, - Resolution { - width: size.width, - height: size.height, - }, + &text_viewport, text_areas, &mut swash_cache, ) @@ -1500,7 +1660,7 @@ async fn render_hero_frame_to_image( render_pass.draw(0..vertices.len() as u32, 0..1); if !text_buffers.is_empty() { text_renderer - .render(&text_atlas, &mut render_pass) + .render(&text_atlas, &text_viewport, &mut render_pass) .context("failed to render hero capture text")?; } } @@ -2359,12 +2519,13 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { size, visible_whole_line_app.text_scale(), ); - body_buffer.set_scroll( - initial_visible_viewport + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: initial_visible_viewport .start_line - .saturating_sub(visible_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(visible_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let mut visible_viewport_ms = 0.0; let mut visible_window_ms = 0.0; @@ -2406,12 +2567,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { let phase_started = Instant::now(); if viewport.start_line != visible_whole_line_start { if let Some(body_buffer) = visible_whole_line_buffers.get_mut(1) { - body_buffer.set_scroll( - viewport - .start_line - .saturating_sub(visible_window_start) - .min(i32::MAX as usize) as i32, - ); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(visible_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } visible_whole_line_start = viewport.start_line; } @@ -2612,12 +2772,13 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { size, streaming_app.text_scale(), ); - body_buffer.set_scroll( - streaming_initial_viewport + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: streaming_initial_viewport .start_line - .saturating_sub(streaming_window_start) - .min(i32::MAX as usize) as i32, - ); + .saturating_sub(streaming_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let mut streaming_previous_key = Some(streaming_initial_key); let mut streaming_tail_text_key = None; @@ -2718,12 +2879,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { &mut streaming_font_system, ); if let Some(body_buffer) = streaming_buffers.get_mut(1) { - body_buffer.set_scroll( - viewport - .start_line - .saturating_sub(streaming_window_start) - .min(i32::MAX as usize) as i32, - ); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(streaming_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let streaming_start_line = streaming_base_len.saturating_add(usize::from(!streaming_app.messages.is_empty())); @@ -2987,12 +3147,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { &mut hero_font_system, ); if let Some(body_buffer) = hero_buffers.get_mut(1) { - body_buffer.set_scroll( - viewport - .start_line - .saturating_sub(hero_window_start) - .min(i32::MAX as usize) as i32, - ); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(hero_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } let hero_visible = key.fresh_welcome_visible; hero_previous_key = Some(key); @@ -3131,12 +3290,11 @@ fn run_scroll_render_benchmark(frames: usize) -> Result<()> { &mut action_font_system, ); if let Some(body_buffer) = action_buffers.get_mut(1) { - body_buffer.set_scroll( - viewport - .start_line - .saturating_sub(action_window_start) - .min(i32::MAX as usize) as i32, - ); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: viewport.start_line.saturating_sub(action_window_start), + vertical: 0.0, + horizontal: 0.0, + }); } action_previous_key = Some(key); action_text_cache_ms += phase_started.elapsed().as_secs_f64() * 1000.0; @@ -3564,6 +3722,7 @@ fn prewarm_desktop_text_renderer( swash_cache: &mut SwashCache, text_atlas: &mut TextAtlas, text_renderer: &mut TextRenderer, + text_viewport: &mut Viewport, device: &wgpu::Device, queue: &wgpu::Queue, size: PhysicalSize, @@ -3575,15 +3734,19 @@ fn prewarm_desktop_text_renderer( if text_areas.is_empty() { return; } - if let Err(error) = text_renderer.prepare( - device, + text_viewport.update( queue, - font_system, - text_atlas, Resolution { width: size.width, height: size.height, }, + ); + if let Err(error) = text_renderer.prepare( + device, + queue, + font_system, + text_atlas, + text_viewport, text_areas, swash_cache, ) { @@ -5526,6 +5689,8 @@ struct Canvas<'window> { render_pipeline: wgpu::RenderPipeline, font_system: Option, swash_cache: SwashCache, + glyphon_cache: Cache, + text_viewport: Viewport, text_atlas: Option, text_renderer: Option, text_needs_prepare: bool, @@ -5588,6 +5753,7 @@ impl<'window> Canvas<'window> { label: Some("jcode-desktop-device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), }, None, ) @@ -5643,6 +5809,7 @@ impl<'window> Canvas<'window> { module: &shader, entry_point: "vs_main", buffers: &[Vertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, @@ -5652,6 +5819,7 @@ impl<'window> Canvas<'window> { blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], + compilation_options: wgpu::PipelineCompilationOptions::default(), }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, @@ -5665,13 +5833,16 @@ impl<'window> Canvas<'window> { depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, + cache: None, }); startup_trace.mark("primitive pipeline ready"); let mut font_system = font_system_loader .take() .and_then(|loader| loader.join().ok()) .unwrap_or_else(create_desktop_font_system); - let mut text_atlas = TextAtlas::new(&device, &queue, format); + let glyphon_cache = Cache::new(&device); + let mut text_viewport = Viewport::new(&device, &glyphon_cache); + let mut text_atlas = TextAtlas::new(&device, &queue, &glyphon_cache, format); let text_renderer = TextRenderer::new( &mut text_atlas, &device, @@ -5686,6 +5857,7 @@ impl<'window> Canvas<'window> { &mut swash_cache, &mut text_atlas, &mut text_renderer, + &mut text_viewport, &device, &queue, size, @@ -5699,6 +5871,8 @@ impl<'window> Canvas<'window> { render_pipeline, font_system: Some(font_system), swash_cache, + glyphon_cache, + text_viewport, text_atlas: Some(text_atlas), text_renderer: Some(text_renderer), text_needs_prepare: true, @@ -5992,11 +6166,11 @@ impl<'window> Canvas<'window> { return; } if let Some(body_buffer) = self.single_session_text_buffers.get_mut(1) { - body_buffer.set_scroll( - start_line - .saturating_sub(window_start) - .min(i32::MAX as usize) as i32, - ); + body_buffer.set_scroll(glyphon::cosmic_text::Scroll { + line: start_line.saturating_sub(window_start), + vertical: 0.0, + horizontal: 0.0, + }); self.single_session_body_text_scroll_start = Some(start_line); self.text_needs_prepare = true; } @@ -6013,7 +6187,12 @@ impl<'window> Canvas<'window> { if self.text_renderer.is_some() { return; } - let mut text_atlas = TextAtlas::new(&self.device, &self.queue, self.config.format); + let mut text_atlas = TextAtlas::new( + &self.device, + &self.queue, + &self.glyphon_cache, + self.config.format, + ); let text_renderer = TextRenderer::new( &mut text_atlas, &self.device, @@ -6029,7 +6208,12 @@ impl<'window> Canvas<'window> { if self.streaming_text_renderer.is_some() { return; } - let mut text_atlas = TextAtlas::new(&self.device, &self.queue, self.config.format); + let mut text_atlas = TextAtlas::new( + &self.device, + &self.queue, + &self.glyphon_cache, + self.config.format, + ); let text_renderer = TextRenderer::new( &mut text_atlas, &self.device, @@ -6239,6 +6423,15 @@ impl<'window> Canvas<'window> { if welcome_hero_reveal_active { self.text_needs_prepare = true; } + if self.text_needs_prepare || self.streaming_text_needs_prepare { + self.text_viewport.update( + &self.queue, + Resolution { + width: self.config.width, + height: self.config.height, + }, + ); + } if self.text_needs_prepare { let text_areas = if let DesktopApp::SingleSession(single_session) = app { single_session_text_areas_for_app_with_cached_body_viewport_and_reveal( @@ -6277,10 +6470,7 @@ impl<'window> Canvas<'window> { &self.queue, font_system, text_atlas, - Resolution { - width: self.config.width, - height: self.config.height, - }, + &self.text_viewport, text_areas, &mut self.swash_cache, ) { @@ -6337,10 +6527,7 @@ impl<'window> Canvas<'window> { &self.queue, font_system, text_atlas, - Resolution { - width: self.config.width, - height: self.config.height, - }, + &self.text_viewport, streaming_text_areas, &mut self.swash_cache, ) { @@ -6453,7 +6640,8 @@ impl<'window> Canvas<'window> { if has_text_buffers && let (Some(text_renderer), Some(text_atlas)) = (self.text_renderer.as_mut(), self.text_atlas.as_ref()) - && let Err(error) = text_renderer.render(text_atlas, &mut render_pass) + && let Err(error) = + text_renderer.render(text_atlas, &self.text_viewport, &mut render_pass) { eprintln!("jcode-desktop: failed to render text: {error:?}"); } @@ -6462,7 +6650,8 @@ impl<'window> Canvas<'window> { self.streaming_text_renderer.as_mut(), self.streaming_text_atlas.as_ref(), ) - && let Err(error) = text_renderer.render(text_atlas, &mut render_pass) + && let Err(error) = + text_renderer.render(text_atlas, &self.text_viewport, &mut render_pass) { eprintln!("jcode-desktop: failed to render streaming text: {error:?}"); } diff --git a/crates/jcode-desktop/src/session_launch.rs b/crates/jcode-desktop/src/session_launch.rs index bac73c51f..44e3fac93 100644 --- a/crates/jcode-desktop/src/session_launch.rs +++ b/crates/jcode-desktop/src/session_launch.rs @@ -105,7 +105,10 @@ pub fn launch_resume_session(session_id: &str, title: &str) -> Result<()> { } pub fn launch_new_session() -> Result<()> { - let candidates = terminal_candidates("jcode · new session", &["--fresh-spawn"]); + // Fresh-spawn is signaled via JCODE_FRESH_SPAWN by jcode-terminal-launch. + // Do not pass --fresh-spawn as a CLI arg: release/test binaries do not + // accept it, which leaves orphan terminal windows and can trigger auth churn. + let candidates = terminal_candidates("jcode · new session", &[]); launch_first_available_terminal(candidates, "jcode") } diff --git a/crates/jcode-desktop/src/single_session_render.rs b/crates/jcode-desktop/src/single_session_render.rs index 27ea382d6..172b38c8c 100644 --- a/crates/jcode-desktop/src/single_session_render.rs +++ b/crates/jcode-desktop/src/single_session_render.rs @@ -4696,7 +4696,7 @@ pub(crate) fn single_session_body_text_buffer_from_lines( content_width, (size.height as f32 - 150.0).max(1.0), ); - buffer.shape_until(font_system, i32::MAX); + buffer.shape_until_scroll(font_system, false); buffer } @@ -5083,7 +5083,7 @@ fn single_session_text_buffer_with_family( height: f32, ) -> Buffer { let mut buffer = Buffer::new(font_system, Metrics::new(font_size, line_height)); - buffer.set_size(font_system, width, height); + buffer.set_size(font_system, Some(width), Some(height)); buffer.set_wrap(font_system, Wrap::Word); buffer.set_text( font_system, @@ -5091,7 +5091,7 @@ fn single_session_text_buffer_with_family( Attrs::new().family(Family::Name(family)), desktop_text_shaping(text), ); - buffer.shape_until_scroll(font_system); + buffer.shape_until_scroll(font_system, false); buffer } @@ -5104,7 +5104,7 @@ fn single_session_styled_text_buffer( height: f32, ) -> Buffer { let mut buffer = Buffer::new(font_system, Metrics::new(font_size, line_height)); - buffer.set_size(font_system, width, height); + buffer.set_size(font_system, Some(width), Some(height)); let segments = single_session_styled_text_segments(lines); let shaping = if segments .iter() @@ -5114,8 +5114,8 @@ fn single_session_styled_text_buffer( } else { Shaping::Basic }; - buffer.set_rich_text(font_system, segments.iter().copied(), shaping); - buffer.shape_until_scroll(font_system); + buffer.set_rich_text(font_system, segments.iter().copied(), Attrs::new(), shaping); + buffer.shape_until_scroll(font_system, false); buffer } @@ -5470,6 +5470,7 @@ pub(crate) fn single_session_hero_font_target_text_areas<'a>( bottom: hero_max[1].ceil() as i32, }, default_color: text_color(WELCOME_HANDWRITING_COLOR), + custom_glyphs: &[], }] } @@ -5501,6 +5502,7 @@ pub(crate) fn single_session_streaming_text_area_for_cached_body_viewport<'a>( as i32, }, default_color: text_color(ASSISTANT_TEXT_COLOR), + custom_glyphs: &[], } } @@ -5614,6 +5616,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom, }, default_color: text_color(STATUS_TEXT_ACCENT_COLOR), + custom_glyphs: &[], }); } else if !welcome_handoff_visible { areas.push(TextArea { @@ -5628,6 +5631,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom, }, default_color: text_color(PANEL_SECTION_COLOR), + custom_glyphs: &[], }); } @@ -5644,6 +5648,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: draft_top as i32, }, default_color: text_color(PANEL_SECTION_COLOR), + custom_glyphs: &[], }); } @@ -5659,6 +5664,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: 64, }, default_color: text_color(PANEL_TITLE_COLOR), + custom_glyphs: &[], }); areas.push(TextArea { buffer: &buffers[4], @@ -5672,6 +5678,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: version_bounds_bottom, }, default_color: text_color(META_TEXT_COLOR), + custom_glyphs: &[], }); areas.push(TextArea { buffer: &buffers[1], @@ -5685,6 +5692,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: body_bottom, }, default_color: text_color(ASSISTANT_TEXT_COLOR), + custom_glyphs: &[], }); if welcome_chrome_visible @@ -5704,6 +5712,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: (hero_max[1] + welcome_chrome_offset_pixels).ceil() as i32, }, default_color: text_color(WELCOME_HANDWRITING_COLOR), + custom_glyphs: &[], }); } @@ -5725,6 +5734,7 @@ pub(crate) fn single_session_text_areas_for_state( bottom: inline_bounds_bottom, }, default_color: text_color(ASSISTANT_TEXT_COLOR), + custom_glyphs: &[], }); } diff --git a/crates/jcode-embedding/src/lib.rs b/crates/jcode-embedding/src/lib.rs index f1b83a39f..fc79fb578 100644 --- a/crates/jcode-embedding/src/lib.rs +++ b/crates/jcode-embedding/src/lib.rs @@ -276,6 +276,7 @@ fn download_model_blocking(model_dir: &Path) -> Result<()> { let client = reqwest::blocking::Client::builder() .user_agent(concat!("jcode-embedding/", env!("CARGO_PKG_VERSION"))) .timeout(std::time::Duration::from_secs(300)) + .no_proxy() .build()?; std::fs::create_dir_all(model_dir)?; diff --git a/crates/jcode-protocol/src/lib.rs b/crates/jcode-protocol/src/lib.rs index 3a65de0dc..9cafec914 100644 --- a/crates/jcode-protocol/src/lib.rs +++ b/crates/jcode-protocol/src/lib.rs @@ -44,6 +44,18 @@ pub enum CommDeliveryMode { Wake, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum CommSpawnMode { + /// Launch a visible terminal session. This preserves the historical behavior. + Visible, + /// Create the agent session in-process/headlessly and skip terminal launch. + Headless, + /// Try a visible terminal first, then fall back to headless on launch failure. + #[default] + Auto, +} + /// A message in conversation history (for sync) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryMessage { @@ -155,6 +167,68 @@ impl AuthChanged { pub type ReloadRecoverySnapshot = jcode_selfdev_types::ReloadRecoveryDirective; +// --- askUserQuestion wire payloads --------------------------------------- +// These mirror crate::ask_user::{AskUserOption,AskUserQuestion,AskUserAnswer, +// AskUserAnswerKind} so the binary can re-export the wire types directly and +// keep one source of truth. Keeping them in `jcode-protocol` lets the server +// and remote client share serde shapes without pulling the whole jcode crate. + +/// A single option offered to the user inside an `askUserQuestion` modal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserOptionPayload { + pub id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recommended: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommendation_reason: Option, +} + +/// The full question payload pushed from the server to the client for +/// rendering in the modal overlay. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserQuestionPayload { + pub request_id: String, + pub session_id: String, + pub question: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + pub options: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_multiple: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// The user's response to an `askUserQuestion` modal, sent client to server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskUserAnswerPayload { + pub request_id: String, + pub kind: AskUserAnswerKindPayload, +} + +/// Discriminated kinds of answer payloads. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AskUserAnswerKindPayload { + /// Option(s) selected. + Options { + ids: Vec, + labels: Vec, + values: Vec>, + }, + /// Free-form text answer. + Custom { text: String }, + /// User dismissed without answering. + Canceled, +} + /// Client request to server #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -204,6 +278,15 @@ pub enum Request { #[serde(rename = "rewind_undo")] RewindUndo { id: u64 }, + /// Submit the user's answer to an outstanding `askUserQuestion` modal. + /// The `request_id` must match the value the server included in the + /// preceding `ServerEvent::AskUserQuestionOpened` event. + #[serde(rename = "submit_ask_user_answer")] + SubmitAskUserAnswer { + id: u64, + answer: AskUserAnswerPayload, + }, + /// Health check #[serde(rename = "ping")] Ping { id: u64 }, @@ -544,6 +627,8 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] initial_message: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + spawn_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] request_nonce: Option, }, @@ -645,6 +730,8 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] spawn_if_needed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + spawn_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] message: Option, }, @@ -1086,6 +1173,13 @@ pub enum ServerEvent { #[serde(rename = "side_panel_state")] SidePanelState { snapshot: SidePanelSnapshot }, + /// `askUserQuestion` tool wants the user to answer a question via the + /// TUI modal overlay. The client is expected to render the modal, capture + /// the user's response, and submit it back via + /// `Request::SubmitAskUserAnswer` carrying the same `request_id`. + #[serde(rename = "ask_user_question_opened")] + AskUserQuestionOpened { question: AskUserQuestionPayload }, + /// Server is reloading (clients should reconnect) #[serde(rename = "reloading")] Reloading { @@ -2048,6 +2142,7 @@ impl Request { Request::CommSubscribeChannel { id, .. } => *id, Request::CommUnsubscribeChannel { id, .. } => *id, Request::CommAwaitMembers { id, .. } => *id, + Request::SubmitAskUserAnswer { id, .. } => *id, } } @@ -2079,6 +2174,7 @@ impl Request { | Request::CommSubscribeChannel { .. } | Request::CommUnsubscribeChannel { .. } | Request::CommAwaitMembers { .. } + | Request::SubmitAskUserAnswer { .. } ) } } diff --git a/crates/jcode-protocol/src/protocol_tests/comm_requests.rs b/crates/jcode-protocol/src/protocol_tests/comm_requests.rs index fe528ce9c..da76a9c40 100644 --- a/crates/jcode-protocol/src/protocol_tests/comm_requests.rs +++ b/crates/jcode-protocol/src/protocol_tests/comm_requests.rs @@ -365,6 +365,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { working_dir: Some("/tmp/project".to_string()), prefer_spawn: Some(true), spawn_if_needed: Some(true), + spawn_mode: Some(CommSpawnMode::Headless), message: Some("Take the next runnable task.".to_string()), }; let json = serde_json::to_string(&req)?; @@ -377,6 +378,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, .. } = decoded @@ -388,6 +390,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { assert_eq!(working_dir.as_deref(), Some("/tmp/project")); assert_eq!(prefer_spawn, Some(true)); assert_eq!(spawn_if_needed, Some(true)); + assert_eq!(spawn_mode, Some(CommSpawnMode::Headless)); assert_eq!(message.as_deref(), Some("Take the next runnable task.")); Ok(()) } @@ -427,10 +430,12 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { session_id: "sess_coord".to_string(), working_dir: Some("/tmp/project".to_string()), initial_message: Some("Start here".to_string()), + spawn_mode: Some(CommSpawnMode::Headless), request_nonce: Some("planner-fresh-123".to_string()), }; let json = serde_json::to_string(&req)?; assert!(json.contains("\"type\":\"comm_spawn\"")); + assert!(json.contains("\"spawn_mode\":\"headless\"")); assert!(json.contains("\"request_nonce\":\"planner-fresh-123\"")); let decoded = parse_request_json(&json)?; assert_eq!(decoded.id(), 59); @@ -438,6 +443,7 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { session_id, working_dir, initial_message, + spawn_mode, request_nonce, .. } = decoded @@ -447,6 +453,7 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { assert_eq!(session_id, "sess_coord"); assert_eq!(working_dir.as_deref(), Some("/tmp/project")); assert_eq!(initial_message.as_deref(), Some("Start here")); + assert_eq!(spawn_mode, Some(CommSpawnMode::Headless)); assert_eq!(request_nonce.as_deref(), Some("planner-fresh-123")); Ok(()) } diff --git a/crates/jcode-provider-core/src/lib.rs b/crates/jcode-provider-core/src/lib.rs index 2457f7ecc..aac00e835 100644 --- a/crates/jcode-provider-core/src/lib.rs +++ b/crates/jcode-provider-core/src/lib.rs @@ -388,6 +388,7 @@ pub fn shared_http_client() -> reqwest::Client { .get_or_init(|| { reqwest::Client::builder() .user_agent(JCODE_USER_AGENT) + .no_proxy() .connect_timeout(Duration::from_secs(15)) .tcp_keepalive(Some(Duration::from_secs(30))) .pool_idle_timeout(Duration::from_secs(90)) @@ -397,6 +398,7 @@ pub fn shared_http_client() -> reqwest::Client { eprintln!("jcode: failed to build shared provider HTTP client: {err}"); reqwest::Client::builder() .user_agent(JCODE_USER_AGENT) + .no_proxy() .build() .expect("fallback Jcode HTTP client should build") }) diff --git a/crates/jcode-provider-core/src/models.rs b/crates/jcode-provider-core/src/models.rs index 9003810b0..5c703c0ad 100644 --- a/crates/jcode-provider-core/src/models.rs +++ b/crates/jcode-provider-core/src/models.rs @@ -1,5 +1,9 @@ /// Available Claude models used by model lists and provider routing. pub const ALL_CLAUDE_MODELS: &[&str] = &[ + "claude-opus-4-7", + "claude-opus-4-7[1m]", + "claude-sonnet-4-7", + "claude-sonnet-4-7[1m]", "claude-opus-4-6", "claude-opus-4-6[1m]", "claude-sonnet-4-6", @@ -14,6 +18,7 @@ pub const ALL_CLAUDE_MODELS: &[&str] = &[ pub const ALL_OPENAI_MODELS: &[&str] = &[ "gpt-5.5", "gpt-5.4", + "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.3-codex", "gpt-5.3-codex-spark", @@ -174,6 +179,14 @@ pub fn context_limit_for_model_with_provider_and_cache( return Some(272_000); } + if model.starts_with("claude-opus-4-7") || model.starts_with("claude-opus-4.7") { + return Some(if is_1m { 1_048_576 } else { 200_000 }); + } + + if model.starts_with("claude-sonnet-4-7") || model.starts_with("claude-sonnet-4.7") { + return Some(if is_1m { 1_048_576 } else { 200_000 }); + } + if model.starts_with("claude-opus-4-6") || model.starts_with("claude-opus-4.6") { return Some(if is_1m { 1_048_576 } else { 200_000 }); } diff --git a/crates/jcode-provider-core/src/pricing.rs b/crates/jcode-provider-core/src/pricing.rs index f895fa4bc..addb472b5 100644 --- a/crates/jcode-provider-core/src/pricing.rs +++ b/crates/jcode-provider-core/src/pricing.rs @@ -126,14 +126,16 @@ pub fn anthropic_oauth_pricing(model: &str, subscription: Option<&str>) -> Route pub fn openai_api_pricing(model: &str) -> Option { let base = model.strip_suffix("[1m]").unwrap_or(model); match base { - "gpt-5.5" | "gpt-5.4" | "gpt-5.4-pro" => Some(RouteCheapnessEstimate::metered( - RouteCostSource::PublicApiPricing, - RouteCostConfidence::High, - usd_to_micros(2.5), - usd_to_micros(15.0), - Some(usd_to_micros(0.25)), - Some("OpenAI API pricing".to_string()), - )), + "gpt-5.5" | "gpt-5.4" | "gpt-5.4-pro" | "gpt-5.4-mini" => { + Some(RouteCheapnessEstimate::metered( + RouteCostSource::PublicApiPricing, + RouteCostConfidence::High, + usd_to_micros(2.5), + usd_to_micros(15.0), + Some(usd_to_micros(0.25)), + Some("OpenAI API pricing".to_string()), + )) + } "gpt-5.3-codex" | "gpt-5.2-codex" | "gpt-5.2" | "gpt-5.1" | "gpt-5.1-codex" => { Some(RouteCheapnessEstimate::metered( RouteCostSource::Heuristic, diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index 64d572b23..fd6e960ee 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -157,7 +157,7 @@ pub const OPENCODE_GO_PROFILE: OpenAiCompatibleProfile = OpenAiCompatibleProfile api_key_env: "OPENCODE_GO_API_KEY", env_file: "opencode-go.env", setup_url: "https://opencode.ai/docs/providers#opencode-go", - default_model: Some("kimi-k2.5"), + default_model: Some("deepseek-v4-pro"), requires_api_key: true, }; @@ -1346,7 +1346,10 @@ mod tests { assert_eq!(OLLAMA_PROFILE.default_model, None); assert!(!OLLAMA_PROFILE.requires_api_key); - assert_eq!(OLLAMA_LOGIN_PROVIDER.auth_kind, LoginProviderAuthKind::Local); + assert_eq!( + OLLAMA_LOGIN_PROVIDER.auth_kind, + LoginProviderAuthKind::Local + ); assert_eq!(OLLAMA_LOGIN_PROVIDER.auth_status_method, "local endpoint"); assert!(matches!( OLLAMA_LOGIN_PROVIDER.target, diff --git a/docker-compose.agent-db.yml b/docker-compose.agent-db.yml new file mode 100644 index 000000000..2436b2dde --- /dev/null +++ b/docker-compose.agent-db.yml @@ -0,0 +1,26 @@ +# Agent DB Substrate — local Postgres for agent analytics +# Well-known credentials; only accessible from localhost. +# Start: docker compose -f docker-compose.agent-db.yml up -d +# Stop: docker compose -f docker-compose.agent-db.yml down -v + +services: + postgres: + image: postgres:16-alpine + container_name: jcode-agent-db + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: jcode_agent + POSTGRES_PASSWORD: jcode_agent_local + POSTGRES_DB: jcode_agent_workspace + volumes: + - jcode_agent_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jcode_agent -d jcode_agent_workspace"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + jcode_agent_db_data: diff --git a/src/agent.rs b/src/agent.rs index ce1c2e76e..e799c5dbd 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -19,7 +19,9 @@ mod utils; use self::streaming::{ send_stream_keepalive_broadcast, send_stream_keepalive_mpsc, stream_keepalive_ticker, }; -use self::tools::{print_tool_summary, tool_output_to_content_blocks}; +use self::tools::{ + maybe_elide_and_cache_tool_output, print_tool_summary, tool_output_to_content_blocks, +}; use self::utils::trace_enabled; use crate::build; use crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus}; diff --git a/src/agent/provider.rs b/src/agent/provider.rs index e60520e21..ceb09cf95 100644 --- a/src/agent/provider.rs +++ b/src/agent/provider.rs @@ -22,15 +22,26 @@ impl Agent { } pub fn available_models_for_switching(&self) -> Vec { - self.provider.available_models_for_switching() + let routes = self.model_routes(); + if !routes.is_empty() { + return crate::provider::listable_model_names_from_routes(&routes); + } + let models = self.provider.available_models_for_switching(); + crate::provider_catalog::filter_models_by_allowlist(self.provider.name(), models) } pub fn available_models_display(&self) -> Vec { - self.provider.available_models_display() + let routes = self.model_routes(); + if !routes.is_empty() { + return crate::provider::listable_model_names_from_routes(&routes); + } + let models = self.provider.available_models_display(); + crate::provider_catalog::filter_models_by_allowlist(self.provider.name(), models) } pub fn model_routes(&self) -> Vec { - self.provider.model_routes() + let routes = self.provider.model_routes(); + crate::provider_catalog::filter_model_routes_by_allowlist(self.provider.name(), routes) } pub fn registry(&self) -> Registry { diff --git a/src/agent/tools.rs b/src/agent/tools.rs index de4baa60b..793287965 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -1,13 +1,21 @@ use crate::message::{ContentBlock, ToolCall}; use crate::tool::ToolOutput; +use chrono::Utc; +use std::fs; +use std::path::PathBuf; + +const TOOL_OUTPUT_ELIDE_THRESHOLD_TOKENS: usize = 500; +const TOOL_OUTPUT_ELIDE_HEAD_TOKENS: usize = 250; +const TOOL_OUTPUT_ELIDE_TAIL_TOKENS: usize = 250; pub(super) fn tool_output_to_content_blocks( tool_use_id: String, output: ToolOutput, ) -> Vec { + let content = maybe_elide_and_cache_tool_output(&tool_use_id, output.output); let mut blocks = vec![ContentBlock::ToolResult { tool_use_id, - content: output.output, + content, is_error: None, }]; for img in output.images { @@ -28,6 +36,97 @@ pub(super) fn tool_output_to_content_blocks( blocks } +pub(super) fn maybe_elide_and_cache_tool_output(tool_use_id: &str, output: String) -> String { + if output.contains("[... elided ") && output.contains("jcode-tool-output-cache") { + return output; + } + + let tokens: Vec<&str> = output.split_whitespace().collect(); + if tokens.len() <= TOOL_OUTPUT_ELIDE_THRESHOLD_TOKENS { + return output; + } + + match cache_full_tool_output(tool_use_id, &output) { + Ok(path) => { + let head = tokens + .iter() + .take(TOOL_OUTPUT_ELIDE_HEAD_TOKENS) + .copied() + .collect::>() + .join(" "); + let tail = tokens + .iter() + .rev() + .take(TOOL_OUTPUT_ELIDE_TAIL_TOKENS) + .copied() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" "); + let omitted = tokens + .len() + .saturating_sub(TOOL_OUTPUT_ELIDE_HEAD_TOKENS + TOOL_OUTPUT_ELIDE_TAIL_TOKENS); + format!( + "{}\n\n[... elided {} middle tokens from tool output; full output cached at {} ...]\n\n{}", + head, + omitted, + path.display(), + tail + ) + } + Err(err) => { + let head = tokens + .iter() + .take(TOOL_OUTPUT_ELIDE_HEAD_TOKENS) + .copied() + .collect::>() + .join(" "); + let tail = tokens + .iter() + .rev() + .take(TOOL_OUTPUT_ELIDE_TAIL_TOKENS) + .copied() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" "); + format!( + "{}\n\n[... elided middle of large tool output; failed to cache full output: {} ...]\n\n{}", + head, err, tail + ) + } + } +} + +fn cache_full_tool_output(tool_use_id: &str, output: &str) -> std::io::Result { + let now = Utc::now(); + let base = std::env::temp_dir() + .join("jcode-tool-output-cache") + .join(now.format("%Y-%m-%d").to_string()) + .join(now.format("%H").to_string()); + fs::create_dir_all(&base)?; + let safe_id: String = tool_use_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + let file = base.join(format!( + "{}-{}-{}.txt", + now.format("%Y%m%dT%H%M%S%.3fZ"), + std::process::id(), + safe_id + )); + fs::write(&file, output)?; + Ok(file) +} + pub(super) fn print_tool_summary(tool: &ToolCall) { match tool.name.as_str() { "bash" => { diff --git a/src/agent/turn_execution.rs b/src/agent/turn_execution.rs index 748c86317..fb76834b7 100644 --- a/src/agent/turn_execution.rs +++ b/src/agent/turn_execution.rs @@ -282,7 +282,23 @@ impl Agent { // Return locked tools if available (prevents cache invalidation from // MCP tools arriving asynchronously after the first API request) if let Some(ref locked) = self.locked_tools { - return locked.clone(); + // Even when the base list is locked, append any tools the agent + // unlocked via ToolSearch since the lock was taken. Without this, + // tools unlocked mid-session would never reach the API. + let unlocked = crate::tool::tool_search::unlocked_for_session(&self.session.id); + if unlocked.is_empty() { + return locked.clone(); + } + let already: std::collections::HashSet<&str> = + locked.iter().map(|t| t.name.as_str()).collect(); + let mut tools = locked.clone(); + let extra = self.registry.definitions_for_names(&unlocked).await; + for def in extra { + if !already.contains(def.name.as_str()) { + tools.push(def); + } + } + return tools; } let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await; @@ -290,6 +306,22 @@ impl Agent { tools.retain(|tool| tool.name != "selfdev"); } + // Append any tools unlocked via ToolSearch that aren't already in the + // base list. For non-OAuth providers this is a no-op since everything + // is already advertised; for OAuth this is what makes deferred tools + // reachable after a ToolSearch call. + let unlocked = crate::tool::tool_search::unlocked_for_session(&self.session.id); + if !unlocked.is_empty() { + let already: std::collections::HashSet = + tools.iter().map(|t| t.name.clone()).collect(); + let extra = self.registry.definitions_for_names(&unlocked).await; + for def in extra { + if !already.contains(&def.name) { + tools.push(def); + } + } + } + // Lock the tool list on first call to prevent cache invalidation // when MCP tools arrive asynchronously mid-session logging::info(&format!( diff --git a/src/agent/turn_streaming_broadcast.rs b/src/agent/turn_streaming_broadcast.rs index a257cef8b..290d40b19 100644 --- a/src/agent/turn_streaming_broadcast.rs +++ b/src/agent/turn_streaming_broadcast.rs @@ -806,11 +806,14 @@ impl Agent { )); match result { - Ok(output) => { + Ok(mut output) => { + let output_text = + maybe_elide_and_cache_tool_output(&tc.id, output.output.clone()); + output.output = output_text.clone(); let _ = event_tx.send(ServerEvent::ToolDone { id: tc.id.clone(), name: tc.name.clone(), - output: output.output.clone(), + output: output_text, error: None, }); diff --git a/src/agent/turn_streaming_mpsc.rs b/src/agent/turn_streaming_mpsc.rs index c0841606e..2c98c97c6 100644 --- a/src/agent/turn_streaming_mpsc.rs +++ b/src/agent/turn_streaming_mpsc.rs @@ -888,11 +888,14 @@ impl Agent { )); match result { - Ok(output) => { + Ok(mut output) => { + let output_text = + maybe_elide_and_cache_tool_output(&tc.id, output.output.clone()); + output.output = output_text.clone(); let _ = event_tx.send(ServerEvent::ToolDone { id: tc.id.clone(), name: tc.name.clone(), - output: output.output.clone(), + output: output_text, error: None, }); diff --git a/src/agent_tests.rs b/src/agent_tests.rs index d8d333b50..a0b6f325e 100644 --- a/src/agent_tests.rs +++ b/src/agent_tests.rs @@ -134,6 +134,43 @@ fn tool_output_to_content_blocks_preserves_labeled_images() { } } +#[test] +fn tool_output_to_content_blocks_elides_and_caches_large_text_output() { + let full_output = (0..650) + .map(|i| format!("tok{i}")) + .collect::>() + .join(" "); + + let blocks = tool_output_to_content_blocks( + "call_large/output".to_string(), + ToolOutput::new(full_output.clone()), + ); + assert_eq!(blocks.len(), 1); + + let content = match &blocks[0] { + ContentBlock::ToolResult { content, .. } => content, + other => panic!("expected tool result, got {other:?}"), + }; + + assert!(content.contains("tok0")); + assert!(content.contains("tok249")); + assert!(content.contains("tok400")); + assert!(content.contains("tok649")); + assert!(content.contains("elided 150 middle tokens")); + assert!(content.contains("jcode-tool-output-cache")); + assert!(!content.contains("tok250 tok251 tok252")); + + let marker = "full output cached at "; + let path_start = content.find(marker).expect("cache marker") + marker.len(); + let path_end = content[path_start..] + .find(" ...]") + .expect("cache marker end") + + path_start; + let cached_path = &content[path_start..path_end]; + let cached = std::fs::read_to_string(cached_path).expect("read cached full tool output"); + assert_eq!(cached, full_output); +} + #[tokio::test] async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { let _guard = crate::storage::lock_test_env(); diff --git a/src/ask_user.rs b/src/ask_user.rs new file mode 100644 index 000000000..792b1529e --- /dev/null +++ b/src/ask_user.rs @@ -0,0 +1,104 @@ +//! Global pending-question registry for the `askUserQuestion` tool. +//! +//! When the tool is invoked it stages an `AskUserQuestion` in this registry, +//! publishes a `BusEvent::AskUserQuestionOpened` so the server can forward it +//! to the active TUI client (local or remote), and `await`s on a oneshot +//! receiver. When the user answers (or cancels) via the modal, the host +//! calls [`submit_answer`] which removes the entry and fulfils the receiver. +//! +//! The wire-level types live in `jcode_protocol`; this module re-exports them +//! under shorter names so call sites can use one canonical type for both the +//! in-process bus event and the cross-process protocol payload. + +use jcode_protocol::{ + AskUserAnswerKindPayload, AskUserAnswerPayload, AskUserOptionPayload, AskUserQuestionPayload, +}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use tokio::sync::oneshot; + +pub type AskUserOption = AskUserOptionPayload; +pub type AskUserQuestion = AskUserQuestionPayload; +pub type AskUserAnswer = AskUserAnswerPayload; +pub type AskUserAnswerKind = AskUserAnswerKindPayload; + +/// Process-wide registry of in-flight ask-user requests. +fn registry() -> &'static Mutex>> { + static R: OnceLock>>> = OnceLock::new(); + R.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Register a pending question and return the receiver half. The caller +/// should then publish `BusEvent::AskUserQuestionOpened` so the host can +/// render the modal, and `await` on the returned receiver. +pub fn register_pending(request_id: String) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + if let Ok(mut map) = registry().lock() { + map.insert(request_id, tx); + } + rx +} + +/// Submit an answer for a previously registered request. Returns true if the +/// request existed and was answered; false if it had already been answered +/// or canceled. +pub fn submit_answer(answer: AskUserAnswer) -> bool { + let tx = match registry().lock() { + Ok(mut map) => map.remove(&answer.request_id), + Err(_) => return false, + }; + match tx { + Some(tx) => tx.send(answer).is_ok(), + None => false, + } +} + +/// Discard a pending request (e.g. session reset) without answering. Any +/// awaiter will observe a closed channel and surface a cancellation error. +#[allow(dead_code)] +pub fn drop_pending(request_id: &str) { + if let Ok(mut map) = registry().lock() { + map.remove(request_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn submit_round_trip() { + let id = "test-ask-user-1".to_string(); + let rx = register_pending(id.clone()); + let ok = submit_answer(AskUserAnswer { + request_id: id.clone(), + kind: AskUserAnswerKind::Custom { + text: "hello".into(), + }, + }); + assert!(ok); + let got = rx.await.expect("answer should arrive"); + assert_eq!(got.request_id, id); + match got.kind { + AskUserAnswerKind::Custom { text } => assert_eq!(text, "hello"), + other => panic!("unexpected kind: {:?}", other), + } + } + + #[tokio::test] + async fn submit_unknown_id_is_false() { + let ok = submit_answer(AskUserAnswer { + request_id: "nope".into(), + kind: AskUserAnswerKind::Canceled, + }); + assert!(!ok); + } + + #[tokio::test] + async fn drop_pending_closes_channel() { + let id = "test-ask-user-drop".to_string(); + let rx = register_pending(id.clone()); + drop_pending(&id); + assert!(rx.await.is_err(), "awaiter should observe closed channel"); + } +} diff --git a/src/bin/publish_selfdev.rs b/src/bin/publish_selfdev.rs new file mode 100644 index 000000000..aa1fbcf88 --- /dev/null +++ b/src/bin/publish_selfdev.rs @@ -0,0 +1,9 @@ +fn main() -> anyhow::Result<()> { + let repo = std::env::args() + .nth(1) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let p = jcode_build_support::publish_local_current_build(&repo)?; + println!("published: {}", p.display()); + Ok(()) +} diff --git a/src/bin/tui_bench.rs b/src/bin/tui_bench.rs index 97d4527ea..943ef25f9 100644 --- a/src/bin/tui_bench.rs +++ b/src/bin/tui_bench.rs @@ -1139,6 +1139,12 @@ impl TuiState for BenchState { None } + fn ask_user_overlay( + &self, + ) -> Option<&std::cell::RefCell> { + None + } + fn working_dir(&self) -> Option { None } diff --git a/src/bus.rs b/src/bus.rs index 72f4f0535..8a1905af4 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -378,6 +378,10 @@ pub enum BusEvent { SidePanelUpdated(SidePanelUpdated), /// Deferred Mermaid rendering completed and cached content may now be visible MermaidRenderCompleted, + /// `askUserQuestion` tool wants the TUI to open a modal for the user to + /// answer interactively. The tool then awaits the answer via + /// [`crate::ask_user::register_pending`] / `submit_answer`. + AskUserQuestionOpened(crate::ask_user::AskUserQuestion), } pub struct Bus { diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 98a3ef235..74d3149d8 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1215,9 +1215,14 @@ pub async fn run_model_command( } let routes = provider.model_routes(); + let routes = crate::provider_catalog::filter_model_routes_by_allowlist(provider.name(), routes); let filtered_routes = filter_cli_model_routes_for_choice(choice, &routes); let models = if filtered_routes.len() == routes.len() { - collect_cli_model_names(&routes, provider.available_models_display()) + let display = crate::provider_catalog::filter_models_by_allowlist( + provider.name(), + provider.available_models_display(), + ); + collect_cli_model_names(&routes, display) } else { collect_cli_model_names(&filtered_routes, Vec::new()) }; diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 8065f4fd6..1b03f9916 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -750,7 +750,21 @@ pub(crate) async fn spawn_server( let client_requested_selfdev = selfdev::client_selfdev_requested(); let exe = build::shared_server_update_candidate(client_requested_selfdev) .map(|(path, _)| path) - .or_else(|| std::env::current_exe().ok()) + .or_else(|| { + // Fall back to current_exe(), but skip cargo test binaries + // (they have a test harness entry point, not jcode's main). + let exe = std::env::current_exe().ok()?; + if exe + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + == Some("deps") + { + None + } else { + Some(exe) + } + }) .ok_or_else(|| anyhow::anyhow!("Could not determine executable path for server spawn"))?; let mut cmd = ProcessCommand::new(&exe); cmd.env_remove(selfdev::CLIENT_SELFDEV_ENV); diff --git a/src/cli/terminal.rs b/src/cli/terminal.rs index c29f44f2c..855f580c9 100644 --- a/src/cli/terminal.rs +++ b/src/cli/terminal.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use std::io::{self, IsTerminal}; +use std::io::{self, IsTerminal, Write}; use std::panic; use crate::{id, session, telemetry, tui}; @@ -170,13 +170,15 @@ pub fn cleanup_tui_runtime_for_run_result( pub fn print_session_resume_hint(session_id: &str) { let session_name = id::extract_session_name(session_id).unwrap_or(session_id); - eprintln!(); - eprintln!( + let mut stderr = io::stderr().lock(); + let _ = writeln!(stderr); + let _ = writeln!( + stderr, "\x1b[33mSession \x1b[1m{}\x1b[0m\x1b[33m - to resume:\x1b[0m", session_name ); - eprintln!(" jcode --resume {}", session_id); - eprintln!(); + let _ = writeln!(stderr, " jcode --resume {}", session_id); + let _ = writeln!(stderr); } fn init_tui_terminal_resume() -> Result { @@ -229,18 +231,27 @@ fn signal_crash_reason(sig: i32) -> String { } } +#[cfg(unix)] +fn signal_should_print_resume_hint(sig: i32) -> bool { + !matches!(sig, libc::SIGHUP) && (io::stderr().is_terminal() || io::stdout().is_terminal()) +} + #[cfg(unix)] fn handle_termination_signal(sig: i32) -> ! { - mark_current_session_crashed(signal_crash_reason(sig)); + if matches!(sig, libc::SIGQUIT) { + mark_current_session_crashed(signal_crash_reason(sig)); + } let _ = crossterm::terminal::disable_raw_mode(); let _ = crossterm::execute!( - std::io::stderr(), + std::io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::cursor::Show ); - if let Some(session_id) = get_current_session() { + if signal_should_print_resume_hint(sig) + && let Some(session_id) = get_current_session() + { print_session_resume_hint(&session_id); } @@ -310,4 +321,10 @@ mod tests { panic!("Session ID should be set"); } } + + #[cfg(unix)] + #[test] + fn test_sighup_does_not_print_resume_hint() { + assert!(!signal_should_print_resume_hint(libc::SIGHUP)); + } } diff --git a/src/cli/tui_launch.rs b/src/cli/tui_launch.rs index d4ad56c20..059189688 100644 --- a/src/cli/tui_launch.rs +++ b/src/cli/tui_launch.rs @@ -455,7 +455,10 @@ pub fn spawn_resume_in_new_terminal_with_provider( provider_key: Option<&str>, ) -> Result { let title = resumed_window_title(session_id); - let mut args = vec!["--fresh-spawn".to_string()]; + // Fresh-spawn is communicated via JCODE_FRESH_SPAWN by TerminalCommand. + // Do not pass --fresh-spawn as a CLI arg: spawned commands may resolve to + // release/test binaries that do not accept hidden dev-only CLI flags. + let mut args = Vec::new(); if let Some(provider_key) = provider_key.filter(|value| !value.trim().is_empty()) { args.push("--provider".to_string()); args.push(provider_key.to_string()); @@ -484,7 +487,10 @@ pub fn spawn_selfdev_in_new_terminal_with_provider( provider_key: Option<&str>, ) -> Result { let selfdev_title = format!("{} [self-dev]", resumed_window_title(session_id)); - let mut args = vec!["--fresh-spawn".to_string()]; + // Fresh-spawn is communicated via JCODE_FRESH_SPAWN by TerminalCommand. + // Do not pass --fresh-spawn as a CLI arg: spawned commands may resolve to + // release/test binaries that do not accept hidden dev-only CLI flags. + let mut args = Vec::new(); if let Some(provider_key) = provider_key.filter(|value| !value.trim().is_empty()) { args.push("--provider".to_string()); args.push(provider_key.to_string()); diff --git a/src/config/default_file.rs b/src/config/default_file.rs index 7d84e618c..ff8812c7a 100644 --- a/src/config/default_file.rs +++ b/src/config/default_file.rs @@ -159,10 +159,10 @@ update_channel = "stable" openai_reasoning_effort = "low" # OpenAI transport mode (auto|websocket|https) # openai_transport = "auto" -# OpenAI service tier override (priority|flex) -# Defaults to `priority` to match Codex /fast behavior for OpenAI OAuth -# (higher speed, higher usage). Set to "off" to disable. -openai_service_tier = "priority" +# OpenAI service tier override (priority|flex|off) +# Defaults to `off` so OpenAI Priority/Fast is never used unless explicitly enabled. +# Set to "priority" or use `/fast on` only when you intentionally want the higher-cost tier. +openai_service_tier = "off" # Cross-provider failover when the same prompt would be resent elsewhere. # countdown = 3-second countdown before retrying on another provider; press Esc to cancel (default) # manual = show a notice and let you switch yourself diff --git a/src/config_tests.rs b/src/config_tests.rs index c0ff4d748..c77e47970 100644 --- a/src/config_tests.rs +++ b/src/config_tests.rs @@ -22,10 +22,10 @@ fn test_openai_reasoning_effort_defaults_to_low() { } #[test] -fn test_openai_fast_mode_defaults_to_priority() { +fn test_openai_fast_mode_defaults_to_off() { assert_eq!( ProviderConfig::default().openai_service_tier.as_deref(), - Some("priority") + Some("off") ); } @@ -44,8 +44,8 @@ fn test_generated_default_config_uses_low_openai_reasoning_effort() { "generated default config should use low OpenAI reasoning effort" ); assert!( - content.contains("openai_service_tier = \"priority\""), - "generated default config should enable OpenAI fast mode" + content.contains("openai_service_tier = \"off\""), + "generated default config should disable OpenAI Priority/Fast mode" ); if let Some(prev) = prev_home { diff --git a/src/lib.rs b/src/lib.rs index 54d25a081..1ca7941d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod agent; pub mod ambient; pub mod ambient_runner; pub mod ambient_scheduler; +pub mod ask_user; pub mod auth; pub mod background; pub mod browser; diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index a146df83c..ba7078890 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -771,7 +771,7 @@ impl AnthropicProvider { /// Adds cache_control to the last tool for prompt caching fn format_tools(&self, tools: &[ToolDefinition], is_oauth: bool) -> Vec { if is_oauth { - return vec![ + let mut hardcoded = vec![ ApiTool { name: "Agent".to_string(), description: "Launch a new agent to handle complex, multi-step tasks." @@ -834,9 +834,34 @@ impl AnthropicProvider { name: "Write".to_string(), description: "Writes a file to the local filesystem.".to_string(), input_schema: json!({"type":"object","properties":{"file_path":{"type":"string"},"content":{"type":"string"}},"required":["file_path","content"],"additionalProperties":false}), - cache_control: Some(CacheControlParam::ephemeral()), + cache_control: None, }, ]; + + // Append any deferred tools the agent has unlocked via ToolSearch. + // The agent passes them through `tools` (in addition to the hardcoded + // OAuth surface) and we forward only the ones not already advertised. + let hardcoded_names: std::collections::HashSet = + hardcoded.iter().map(|t| t.name.clone()).collect(); + for tool in tools { + if hardcoded_names.contains(&tool.name) { + continue; + } + hardcoded.push(ApiTool { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.input_schema.clone(), + cache_control: None, + }); + } + + // Move ephemeral cache_control to the actual last tool so caching + // still works across the dynamic suffix. + if let Some(last) = hardcoded.last_mut() { + last.cache_control = Some(CacheControlParam::ephemeral()); + } + + return hardcoded; } let len = tools.len(); diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index 9fe39d31e..930dedc55 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -777,6 +777,7 @@ fn is_vpc_sc_error(err: &anyhow::Error) -> bool { fn gemini_http_client() -> reqwest::Client { reqwest::Client::builder() .user_agent("jcode/1.0 (gemini)") + .no_proxy() .http1_only() .connect_timeout(Duration::from_secs(20)) .timeout(Duration::from_secs(90)) diff --git a/src/provider/mod.rs b/src/provider/mod.rs index c973e898f..c3071d590 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -332,6 +332,21 @@ impl MultiProvider { Some((profile, rest)) } + fn named_openai_compatible_model_prefix(model: &str) -> Option<(String, &str)> { + let (prefix, rest) = model.split_once(':')?; + let prefix = prefix.trim(); + let rest = rest.trim(); + if prefix.is_empty() || rest.is_empty() { + return None; + } + let cfg = crate::config::config(); + if cfg.providers.contains_key(prefix) { + Some((prefix.to_string(), rest)) + } else { + None + } + } + fn ensure_provider_lock_allows_model_target( &self, target: ActiveProvider, @@ -494,6 +509,38 @@ impl MultiProvider { Ok(()) } + fn set_model_on_named_openai_compatible_profile( + &self, + profile_name: &str, + model: &str, + ) -> Result<()> { + let model = model.trim(); + if model.is_empty() { + anyhow::bail!("Model cannot be empty"); + } + + crate::provider_catalog::apply_named_provider_profile_env(profile_name)?; + let cfg = crate::config::config(); + let profile = cfg.providers.get(profile_name).ok_or_else(|| { + anyhow::anyhow!( + "Unknown provider profile '{}'. Add [providers.{}] to config.toml.", + profile_name, + profile_name + ) + })?; + let provider = Arc::new(openrouter::OpenRouterProvider::new_named_openai_compatible( + profile_name, + profile, + )?); + provider.set_model(model)?; + *self + .openrouter + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(provider); + self.set_active_provider(ActiveProvider::OpenRouter); + Ok(()) + } + fn should_replace_openrouter_after_auth_change( existing: &openrouter::OpenRouterProvider, candidate: &openrouter::OpenRouterProvider, @@ -876,6 +923,13 @@ impl Provider for MultiProvider { return self.set_model_on_openai_compatible_profile(profile, target_model); } + if let Some((profile_name, target_model)) = + Self::named_openai_compatible_model_prefix(requested_model) + { + self.ensure_provider_lock_allows_openai_compatible_profile(requested_model)?; + return self.set_model_on_named_openai_compatible_profile(&profile_name, target_model); + } + // Provider-prefixed model names are explicit routing directives. They // must never silently fall through to another provider when the target // is unavailable or when --provider locks a different backend. @@ -1115,12 +1169,30 @@ impl Provider for MultiProvider { .iter() .copied() { + let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); + if !crate::provider_catalog::provider_is_enabled(&resolved.id) { + continue; + } if !crate::provider_catalog::openai_compatible_profile_is_configured(profile) { continue; } - let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); let api_method = format!("openai-compatible:{}", resolved.id); - for model in crate::provider_catalog::openai_compatible_profile_static_models(profile) { + let mut profile_models = + crate::provider_catalog::openai_compatible_profile_static_models(profile); + if let Some(allowlist_models) = crate::config::config() + .provider + .model_allowlist + .get(&resolved.id) + { + for model in allowlist_models { + let model = model.trim().strip_prefix('=').unwrap_or(model.trim()); + if !model.is_empty() && !profile_models.iter().any(|existing| existing == model) + { + profile_models.push(model.to_string()); + } + } + } + for model in profile_models { let already_present = routes.iter().any(|route| { route.model == model && route.provider == resolved.display_name @@ -1143,8 +1215,88 @@ impl Provider for MultiProvider { } } + let cfg = crate::config::config(); + for (profile_name, profile) in &cfg.providers { + if !crate::provider_catalog::provider_is_enabled(profile_name) { + continue; + } + let api_method = format!("openai-compatible:{}", profile_name); + let provider_label = + crate::provider_catalog::openai_compatible_profile_by_id(profile_name) + .map(|profile| { + crate::provider_catalog::resolve_openai_compatible_profile(profile) + .display_name + }) + .unwrap_or_else(|| profile_name.clone()); + let mut named_models = Vec::new(); + if let Some(default_model) = profile.default_model.as_deref().map(str::trim) + && !default_model.is_empty() + { + named_models.push(default_model.to_string()); + } + for model in &profile.models { + let id = model.id.trim(); + if !id.is_empty() && !named_models.iter().any(|existing| existing == id) { + named_models.push(id.to_string()); + } + } + if let Some(allowlist_models) = cfg.provider.model_allowlist.get(profile_name) { + for model in allowlist_models { + let model = model.trim().strip_prefix('=').unwrap_or(model.trim()); + if !model.is_empty() && !named_models.iter().any(|existing| existing == model) { + named_models.push(model.to_string()); + } + } + } + let requires_key = profile.requires_api_key.unwrap_or_else(|| { + !crate::provider_catalog::api_base_uses_localhost(&profile.base_url) + }); + let has_credentials = match profile.auth { + crate::config::NamedProviderAuth::None => true, + crate::config::NamedProviderAuth::Bearer + | crate::config::NamedProviderAuth::Header => { + !requires_key + || profile + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || profile + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|env_key| { + if let Some(env_file) = profile.env_file.as_deref() { + crate::provider_catalog::load_env_value_from_env_or_config( + env_key, env_file, + ) + } else { + std::env::var(env_key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + }) + .is_some() + } + }; + for model in named_models { + routes.push(ModelRoute { + model, + provider: provider_label.clone(), + api_method: api_method.clone(), + available: has_credentials, + detail: profile.base_url.clone(), + cheapness: None, + }); + added_direct_openai_compatible_routes = true; + } + } + // GitHub Copilot models - { + if crate::provider_catalog::provider_is_enabled("copilot") { if let Some(copilot) = self.copilot_provider() { let copilot_models = copilot.available_models_display(); let detail = copilot.model_catalog_detail(); @@ -1381,7 +1533,10 @@ impl Provider for MultiProvider { )); } - dedupe_model_routes(routes) + crate::provider_catalog::filter_model_routes_by_allowlist( + "multi-provider", + dedupe_model_routes(routes), + ) } async fn prefetch_models(&self) -> Result<()> { @@ -1866,25 +2021,26 @@ impl Provider for MultiProvider { let current_model = self.model(); let active = self.active_provider(); - let claude = if matches!(active, ActiveProvider::Claude) && self.claude_provider().is_some() - { - Some(Arc::new(claude::ClaudeProvider::new())) - } else { - None - }; - let anthropic = if self.anthropic_provider().is_some() { - Some(Arc::new(anthropic::AnthropicProvider::new())) - } else { - None - }; - let openai = if self.openai_provider().is_some() { - auth::codex::load_credentials() - .ok() - .map(openai::OpenAIProvider::new) - .map(Arc::new) - } else { - None - }; + // Shallow-clone all provider slots so no re-construction occurs. + // Each read is a cheap RwLock read + Option::clone (atomic increment). + // If a non-active slot is later needed by failover, + // reconcile_auth_if_provider_missing / handle_auth_changed will + // hot-initialize it on demand. + let claude = self + .claude + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let anthropic = self + .anthropic + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let openai = self + .openai + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); let copilot_api = self .copilot_api .read() @@ -1900,31 +2056,21 @@ impl Provider for MultiProvider { .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); - let cursor_provider = if self + let cursor_provider = self .cursor .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some() - { - Some(Arc::new(cursor::CursorCliProvider::new())) - } else { - None - }; - let bedrock_provider = if self.bedrock_provider().is_some() { - Some(Arc::new(bedrock::BedrockProvider::new())) - } else { - None - }; - let openrouter = if self + .clone(); + let bedrock_provider = self + .bedrock + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let openrouter = self .openrouter .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some() - { - openrouter::OpenRouterProvider::new().ok().map(Arc::new) - } else { - None - }; + .clone(); let provider = Self { claude: RwLock::new(claude), @@ -1942,8 +2088,6 @@ impl Provider for MultiProvider { forced_provider: self.forced_provider, }; - provider.spawn_anthropic_catalog_refresh_if_needed(); - provider.spawn_openai_catalog_refresh_if_needed(); if matches!(active, ActiveProvider::Copilot) { let _ = provider.set_model(&format!("copilot:{}", current_model)); } else if matches!(active, ActiveProvider::Antigravity) { diff --git a/src/provider/openrouter_sse_stream.rs b/src/provider/openrouter_sse_stream.rs index bf374d3f9..a2e225430 100644 --- a/src/provider/openrouter_sse_stream.rs +++ b/src/provider/openrouter_sse_stream.rs @@ -363,12 +363,12 @@ impl OpenRouterStream { .and_then(|c| c.as_str()) && !reasoning_content.is_empty() { - let reasoning_delta = if reasoning_content.starts_with(&self.reasoning_buffer) - { - &reasoning_content[self.reasoning_buffer.len()..] - } else { - reasoning_content - }; + let reasoning_delta = + if reasoning_content.starts_with(&self.reasoning_buffer) { + &reasoning_content[self.reasoning_buffer.len()..] + } else { + reasoning_content + }; self.reasoning_buffer = reasoning_content.to_string(); if !reasoning_delta.is_empty() { self.pending diff --git a/src/provider/openrouter_tests.rs b/src/provider/openrouter_tests.rs index b44902e44..cdec5ee1c 100644 --- a/src/provider/openrouter_tests.rs +++ b/src/provider/openrouter_tests.rs @@ -1159,7 +1159,8 @@ fn test_parse_next_event_emits_only_incremental_reasoning_content() { } stream.buffer = - "data:{\"choices\":[{\"delta\":{\"reasoning_content\":\"Thinking more\"}}]}\n\n".to_string(); + "data:{\"choices\":[{\"delta\":{\"reasoning_content\":\"Thinking more\"}}]}\n\n" + .to_string(); match stream.parse_next_event() { Some(StreamEvent::ThinkingDelta(text)) => assert_eq!(text, " more"), other => panic!("expected incremental ThinkingDelta, got {:?}", other), diff --git a/src/provider/startup.rs b/src/provider/startup.rs index 086ff6421..505a1a5cb 100644 --- a/src/provider/startup.rs +++ b/src/provider/startup.rs @@ -69,6 +69,21 @@ impl MultiProvider { let cfg = crate::config::config(); let provider_state = ProviderState::from_parts(cfg, &auth_status); let mut default_named_provider_profile: Option = None; + + // When no explicit CLI provider profile override is active, clear any + // stale named-provider env vars that may be lingering from a previous + // session's shell environment. Without this, stale + // JCODE_NAMED_PROVIDER_PROFILE / JCODE_ACTIVE_PROVIDER / etc. will + // block the config.toml default_provider from taking effect, causing + // the model picker and session provider key to resolve to the wrong + // provider. + if std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() { + crate::env::remove_var("JCODE_NAMED_PROVIDER_PROFILE"); + crate::env::remove_var("JCODE_OPENROUTER_CACHE_NAMESPACE"); + crate::env::remove_var("JCODE_ACTIVE_PROVIDER"); + crate::env::remove_var("JCODE_RUNTIME_PROVIDER"); + } + if std::env::var_os("JCODE_PROVIDER_PROFILE_ACTIVE").is_none() && std::env::var_os("JCODE_NAMED_PROVIDER_PROFILE").is_none() && let Some(pref) = provider_state.default_provider_key() @@ -236,6 +251,7 @@ impl MultiProvider { openrouter: openrouter.is_some(), copilot_premium_zero, }; + let availability = Self::filter_availability_by_enabled_providers(availability); let mut active = Self::auto_default_provider(availability); if copilot_premium_zero && matches!(active, ActiveProvider::Copilot) { @@ -433,6 +449,43 @@ impl MultiProvider { Self::new_with_auth_status(auth_status) } + fn filter_availability_by_enabled_providers( + availability: ProviderAvailability, + ) -> ProviderAvailability { + let cfg = crate::config::config(); + if cfg.provider.enabled_providers.is_empty() { + return availability; + } + + ProviderAvailability { + openai: availability.openai && crate::provider_catalog::provider_is_enabled("openai"), + claude: availability.claude + && crate::provider_catalog::provider_is_enabled("anthropic"), + copilot: availability.copilot + && crate::provider_catalog::provider_is_enabled("copilot"), + antigravity: availability.antigravity + && crate::provider_catalog::provider_is_enabled("antigravity"), + gemini: availability.gemini && crate::provider_catalog::provider_is_enabled("gemini"), + cursor: availability.cursor && crate::provider_catalog::provider_is_enabled("cursor"), + bedrock: availability.bedrock + && crate::provider_catalog::provider_is_enabled("bedrock"), + // OpenRouter backs both the raw OpenRouter provider and all + // OpenAI-compatible/named profiles. Keep it eligible when any + // enabled provider entry points at that shared transport; the route + // filter later hides non-enabled profiles/models. + openrouter: availability.openrouter + && (crate::provider_catalog::provider_is_enabled("openrouter") + || cfg.provider.enabled_providers.iter().any(|provider| { + crate::provider_catalog::resolve_openai_compatible_profile_selection( + provider, + ) + .is_some() + || cfg.providers.contains_key(provider.trim()) + })), + copilot_premium_zero: availability.copilot_premium_zero, + } + } + /// Create with explicit initial provider preference pub fn with_preference(prefer_openai: bool) -> Self { let provider = Self::new(); diff --git a/src/provider/tests.rs b/src/provider/tests.rs index 68ef89adf..17939cad6 100644 --- a/src/provider/tests.rs +++ b/src/provider/tests.rs @@ -232,3 +232,4 @@ include!("tests/auth_refresh.rs"); include!("tests/model_resolution.rs"); include!("tests/fallback_failover.rs"); include!("tests/catalog_subscription.rs"); +include!("tests/startup_stale_env.rs"); diff --git a/src/provider/tests/model_resolution.rs b/src/provider/tests/model_resolution.rs index 0147d85f5..2d82a833b 100644 --- a/src/provider/tests/model_resolution.rs +++ b/src/provider/tests/model_resolution.rs @@ -627,6 +627,68 @@ fn test_configured_direct_compatible_profiles_are_listed_without_openrouter_key( }); } +#[test] +fn test_named_provider_allowlist_exact_markers_are_not_model_ids() { + with_clean_provider_test_env(|| { + let config_path = crate::storage::jcode_dir() + .expect("temp jcode home") + .join("config.toml"); + std::fs::write( + config_path, + r#" +[provider] +enabled_providers = ["ollama-cloud"] + +[provider.model_allowlist] +ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] + +[providers.ollama-cloud] +type = "openai-compatible" +base_url = "http://localhost:11434/v1" +auth = "none" +default_model = "deepseek-v4-pro" +"#, + ) + .expect("write temp config"); + crate::config::invalidate_config_cache(); + + let provider = MultiProvider { + claude: RwLock::new(None), + anthropic: RwLock::new(None), + openai: RwLock::new(None), + copilot_api: RwLock::new(None), + antigravity: RwLock::new(None), + gemini: RwLock::new(None), + cursor: RwLock::new(None), + bedrock: RwLock::new(None), + openrouter: RwLock::new(None), + active: RwLock::new(ActiveProvider::OpenRouter), + use_claude_cli: false, + startup_notices: RwLock::new(Vec::new()), + forced_provider: Some(ActiveProvider::OpenRouter), + }; + + let routes = provider.model_routes(); + assert!(routes.iter().any(|route| { + route.model == "deepseek-v4-pro" + && route.provider == "ollama-cloud" + && route.api_method == "openai-compatible:ollama-cloud" + })); + assert!(routes.iter().any(|route| { + route.model == "deepseek-v4-flash" + && route.provider == "ollama-cloud" + && route.api_method == "openai-compatible:ollama-cloud" + })); + assert!( + !routes.iter().any(|route| route.model.starts_with('=')), + "allowlist exact-match markers must not leak into model ids: {:?}", + routes + ); + + crate::config::invalidate_config_cache(); + }); +} + #[test] fn test_profile_prefixed_model_switch_reinitializes_direct_compatible_runtime() { with_clean_provider_test_env(|| { diff --git a/src/provider/tests/startup_stale_env.rs b/src/provider/tests/startup_stale_env.rs new file mode 100644 index 000000000..c27221c27 --- /dev/null +++ b/src/provider/tests/startup_stale_env.rs @@ -0,0 +1,109 @@ +/// Regression test: when stale named-provider env vars leak into the +/// process from a previous jcode session's shell environment, +/// `new_with_auth_status` must clear them before the guard so +/// config.toml's `default_provider` can take effect. The key assertion +/// is that the stale profile is NOT used and config.toml wins. +#[test] +fn startup_clears_stale_named_provider_env_vars_when_no_cli_override() { + with_clean_provider_test_env(|| { + let cfg_path = crate::config::Config::path().expect("config path in test"); + std::fs::create_dir_all(cfg_path.parent().expect("config parent")) + .expect("create config dir"); + let toml = r#" +[providers.my-profile] +provider_type = "openai-compatible" +base_url = "https://llm.example.test/v1" +default_model = "my-gpt" + +[provider] +default_provider = "my-profile" +"#; + std::fs::write(&cfg_path, toml).expect("write config.toml"); + + // Stale env vars simulating a previous session's shell env. + // JCODE_PROVIDER_PROFILE_ACTIVE is NOT set, so no CLI override. + crate::env::set_var("JCODE_NAMED_PROVIDER_PROFILE", "stale-previous-profile"); + crate::env::set_var("JCODE_OPENROUTER_CACHE_NAMESPACE", "stale-namespace"); + crate::env::set_var("JCODE_ACTIVE_PROVIDER", "openrouter"); + crate::env::set_var("JCODE_RUNTIME_PROVIDER", "stale-runtime"); + + crate::config::Config::invalidate_cache(); + + let auth = crate::auth::AuthStatus::check(); + let provider = MultiProvider::new_with_auth_status(auth); + + // The config.toml default_provider="my-profile" should have + // taken effect (the stale vars were cleared first, then the + // guard applied the correct profile). + assert_eq!( + std::env::var("JCODE_PROVIDER_PROFILE_ACTIVE") + .ok() + .as_deref(), + Some("1"), + "profile should be active after default_provider is applied" + ); + assert_eq!( + std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .as_deref(), + Some("my-profile"), + "named profile should be config.toml's my-profile, not stale-previous-profile" + ); + + // The provider may resolve to Claude as a fallback when + // the named profile has no live credentials, but the env + // vars confirm the correct profile was applied. + let _ = provider.active_provider(); + }); +} + +/// When JCODE_PROVIDER_PROFILE_ACTIVE is explicitly set (CLI override), +/// stale env vars should NOT be cleared and the explicit profile should +/// be preserved. +#[test] +fn startup_preserves_explicit_cli_profile_override() { + with_clean_provider_test_env(|| { + let cfg_path = crate::config::Config::path().expect("config path in test"); + std::fs::create_dir_all(cfg_path.parent().expect("config parent")) + .expect("create config dir"); + let toml = r#" +[providers.cli-profile] +provider_type = "openai-compatible" +base_url = "https://cli.example.test/v1" +default_model = "cli-model" + +[provider] +default_provider = "my-profile" +"#; + std::fs::write(&cfg_path, toml).expect("write config.toml"); + + // Explicit CLI override: JCODE_PROVIDER_PROFILE_ACTIVE is set. + crate::env::set_var("JCODE_PROVIDER_PROFILE_ACTIVE", "1"); + crate::env::set_var("JCODE_NAMED_PROVIDER_PROFILE", "cli-profile"); + crate::env::set_var("JCODE_OPENROUTER_CACHE_NAMESPACE", "cli-profile"); + + crate::config::Config::invalidate_cache(); + + let auth = crate::auth::AuthStatus::check(); + let provider = MultiProvider::new_with_auth_status(auth); + + // With an explicit CLI override, the stale env vars should NOT + // be cleared. + assert_eq!( + std::env::var("JCODE_PROVIDER_PROFILE_ACTIVE") + .ok() + .as_deref(), + Some("1"), + "explicit CLI override should be preserved" + ); + assert_eq!( + std::env::var("JCODE_NAMED_PROVIDER_PROFILE") + .ok() + .as_deref(), + Some("cli-profile"), + "explicit profile should be preserved" + ); + + let _ = provider; // just ensure construction succeeded + }); +} diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 61931f1f1..d7011aca1 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -131,6 +131,47 @@ pub fn active_openai_compatible_display_name() -> Option { None } +/// Return the active openai-compatible profile id (e.g. `opencode-go`, +/// `ollama-cloud`, `deepseek`, ...) when the openrouter-style multi-profile +/// provider is in use. The profile id is the canonical key under which +/// users keep `[providers.]` blocks in `config.toml` and also the key +/// expected by the `[provider.model_allowlist]` map for non-OAuth profiles. +pub fn active_openai_compatible_profile_id() -> Option { + if let Ok(profile_name) = std::env::var("JCODE_NAMED_PROVIDER_PROFILE") { + let trimmed = profile_name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_ascii_lowercase()); + } + } + + if let Ok(namespace) = std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE") { + let trimmed = namespace.trim(); + if !trimmed.is_empty() + && openai_compatible_profiles() + .iter() + .any(|profile| profile.id == trimmed) + { + return Some(trimmed.to_ascii_lowercase()); + } + } + + let api_base = std::env::var("JCODE_OPENROUTER_API_BASE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| env_override("JCODE_OPENAI_COMPAT_API_BASE")); + + let api_base = api_base.and_then(|value| normalize_api_base(&value))?; + + for profile in openai_compatible_profiles().iter().copied() { + if normalize_api_base(profile.api_base).as_deref() == Some(api_base.as_str()) { + return Some(profile.id.to_string()); + } + } + + None +} + pub fn runtime_provider_display_name(provider_name: &str) -> String { if provider_name.eq_ignore_ascii_case("openrouter") { if let Ok(runtime_provider) = std::env::var("JCODE_RUNTIME_PROVIDER") @@ -145,6 +186,206 @@ pub fn runtime_provider_display_name(provider_name: &str) -> String { } } +/// Look up the per-provider model allowlist from `[provider.model_allowlist]`. +/// +/// Returns `Some(patterns)` only when the provider has at least one configured +/// pattern. An empty list (or missing entry) means "no restriction". +/// +/// The lookup is keyed by the raw built-in provider key returned from +/// `Provider::name()` (e.g. `anthropic`, `openai`, `gemini`, `antigravity`) +/// but is also accepted under common aliases such as `claude`. +pub fn provider_model_allowlist_patterns(provider_name: &str) -> Option> { + let cfg = crate::config::config(); + if cfg.provider.model_allowlist.is_empty() { + return None; + } + + let keys = provider_config_candidate_keys(provider_name); + + for key in &keys { + if let Some(patterns) = cfg.provider.model_allowlist.get(key) { + let cleaned: Vec = patterns + .iter() + .map(|pattern| pattern.trim().to_string()) + .filter(|pattern| !pattern.is_empty()) + .collect(); + if !cleaned.is_empty() { + return Some(cleaned); + } + } + } + None +} + +fn canonical_provider_config_key(provider_name: &str) -> String { + let trimmed = provider_name.trim().to_ascii_lowercase(); + match trimmed.as_str() { + "claude" => "anthropic".to_string(), + "github copilot" => "copilot".to_string(), + "aws bedrock" | "aws-bedrock" | "aws_bedrock" => "bedrock".to_string(), + "google gemini" => "gemini".to_string(), + _ => trimmed, + } +} + +fn push_unique(values: &mut Vec, value: impl Into) { + let value = value.into(); + if !value.is_empty() && !values.iter().any(|existing| existing == &value) { + values.push(value); + } +} + +fn provider_config_candidate_keys(provider_name: &str) -> Vec { + let canonical = canonical_provider_config_key(provider_name); + let mut keys = Vec::new(); + push_unique(&mut keys, canonical.clone()); + if canonical == "anthropic" { + push_unique(&mut keys, "claude"); + } else if canonical == "claude" { + push_unique(&mut keys, "anthropic"); + } + // The openrouter provider impl backs every openai-compatible profile + // (opencode-go, ollama-cloud, deepseek, etc.). Also accept the active + // profile id as a lookup key so the user can write + // `[provider.model_allowlist] opencode-go = [...]`. + if canonical == "openrouter" { + if let Some(profile_id) = active_openai_compatible_profile_id() { + push_unique(&mut keys, profile_id); + } + } + if let Some(profile_id) = openai_compatible_profile_id_for_display_name(provider_name) { + push_unique(&mut keys, profile_id); + } + keys +} + +/// Return whether a provider/profile should be visible and auto-selectable under +/// `[provider].enabled_providers`. An empty list means every configured provider +/// is enabled. Values may be built-in provider keys (`anthropic`, `openai`, +/// `copilot`, ...), OpenAI-compatible profile ids (`opencode-go`, `deepseek`), +/// display names, or named provider profile names. +pub fn provider_is_enabled(provider_name: &str) -> bool { + let cfg = crate::config::config(); + if cfg.provider.enabled_providers.is_empty() { + return true; + } + + let candidate_keys = provider_config_candidate_keys(provider_name); + cfg.provider + .enabled_providers + .iter() + .map(|value| canonical_provider_config_key(value)) + .any(|enabled| { + candidate_keys.iter().any(|candidate| candidate == &enabled) + || openai_compatible_profile_id_for_display_name(&enabled) + .map(|profile_id| { + candidate_keys + .iter() + .any(|candidate| candidate == profile_id) + }) + .unwrap_or(false) + }) +} + +/// Filter a list of model names against the configured allowlist for `provider_name`. +/// +/// Matching is case-insensitive and accepts either an exact match or a +/// substring match against any configured pattern. When no allowlist is +/// configured for the provider, the input is returned unchanged. +pub fn filter_models_by_allowlist(provider_name: &str, models: Vec) -> Vec { + if !provider_is_enabled(provider_name) { + return Vec::new(); + } + + let Some(patterns) = provider_model_allowlist_patterns(provider_name) else { + return models; + }; + let lower_patterns: Vec = patterns.iter().map(|p| p.to_ascii_lowercase()).collect(); + models + .into_iter() + .filter(|model| model_matches_any(&model.to_ascii_lowercase(), &lower_patterns)) + .collect() +} + +/// Filter `ModelRoute`s against the configured allowlist for `provider_name`. +/// +/// When the supplied list contains routes from multiple back-end providers +/// (as is the case for the aggregated `MultiProvider::model_routes()`), each +/// route is evaluated against the allowlist that matches its own +/// `route.provider` / `route.api_method` rather than the surrounding +/// `provider_name`. This keeps cross-provider model pickers honest: a +/// configured allowlist for `anthropic`, `openai`, `opencode-go`, ... only +/// hides models for the providers the user has explicitly restricted, and +/// leaves the others unrestricted. +pub fn filter_model_routes_by_allowlist( + provider_name: &str, + routes: Vec, +) -> Vec { + let cfg = crate::config::config(); + if cfg.provider.model_allowlist.is_empty() && cfg.provider.enabled_providers.is_empty() { + return routes; + } + + routes + .into_iter() + .filter(|route| { + // Resolve which allowlist key applies to this specific route. + let route_key = allowlist_key_for_route(route) + .unwrap_or_else(|| canonical_provider_config_key(provider_name)); + if !provider_is_enabled(&route_key) { + return false; + } + match provider_model_allowlist_patterns(&route_key) { + Some(patterns) => { + let lower_patterns: Vec = + patterns.iter().map(|p| p.to_ascii_lowercase()).collect(); + model_matches_any(&route.model.to_ascii_lowercase(), &lower_patterns) + } + None => true, + } + }) + .collect() +} + +/// Map a route's `(provider, api_method)` to the canonical allowlist key +/// used in `[provider.model_allowlist]`. Returns `None` when the route does +/// not map to a well-known key, in which case the caller falls back to the +/// surrounding provider context. +fn allowlist_key_for_route(route: &crate::provider::ModelRoute) -> Option { + let api_method = route.api_method.to_ascii_lowercase(); + let provider_display = route.provider.to_ascii_lowercase(); + + // openai-compatible profile routes embed their profile id in api_method. + if let Some(profile_id) = api_method.strip_prefix("openai-compatible:") { + return Some(profile_id.to_string()); + } + + if api_method == "openrouter" { + return Some("openrouter".to_string()); + } + + match (provider_display.as_str(), api_method.as_str()) { + ("anthropic", _) => Some("anthropic".to_string()), + ("openai", _) => Some("openai".to_string()), + ("gemini", _) => Some("gemini".to_string()), + ("antigravity", _) => Some("antigravity".to_string()), + ("copilot", _) => Some("copilot".to_string()), + ("cursor", _) => Some("cursor".to_string()), + ("aws bedrock", _) => Some("bedrock".to_string()), + _ => None, + } +} + +fn model_matches_any(model_lower: &str, lower_patterns: &[String]) -> bool { + lower_patterns.iter().any(|pattern| { + if let Some(exact) = pattern.strip_prefix('=') { + model_lower == exact + } else { + model_lower == pattern || model_lower.contains(pattern) + } + }) +} + pub fn openai_compatible_profile_by_id(id: &str) -> Option { let normalized = id.trim().to_ascii_lowercase(); openai_compatible_profiles() @@ -201,6 +442,7 @@ pub fn openai_compatible_profile_static_models(profile: OpenAiCompatibleProfile) push("kimi-k2.5"); push("glm-5"); push("glm-5.1"); + push("deepseek-v4-pro"); push("deepseek-v4-flash"); push("qwen3.5-plus"); } @@ -392,10 +634,12 @@ pub fn openai_compatible_profile_context_limit(profile_id: &str, model: &str) -> let model = model.trim().to_ascii_lowercase(); match profile_id.as_str() { - // DeepSeek V4 direct API models advertise a 1M token context window. The - // direct profile runs through the OpenRouter/OpenAI-compatible provider - // implementation, whose live catalog can be unavailable during startup. - "deepseek" if model.starts_with("deepseek-v4-") => Some(1_000_000), + // DeepSeek V4 models advertise a 1M token context window. These + // providers run through the OpenRouter/OpenAI-compatible implementation, + // whose live catalog can be unavailable during startup. + "deepseek" | "opencode-go" | "ollama-cloud" if model.starts_with("deepseek-v4-") => { + Some(1_000_000) + } _ => None, } } diff --git a/src/provider_catalog_tests.rs b/src/provider_catalog_tests.rs index 4c4de00ed..b76eb686e 100644 --- a/src/provider_catalog_tests.rs +++ b/src/provider_catalog_tests.rs @@ -147,6 +147,70 @@ fn auth_issue_runtime_display_name_tracks_direct_compatible_profiles() { assert_eq!(runtime_provider_display_name("openrouter"), "Z.AI"); } +#[test] +fn provider_enabled_and_model_allowlists_filter_cross_provider_routes() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::save(&["JCODE_HOME"]); + crate::env::set_var("JCODE_HOME", temp.path()); + std::fs::write( + temp.path().join("config.toml"), + r#" +[provider] +enabled_providers = ["openai", "ollama-cloud"] + +[provider.model_allowlist] +openai = ["=gpt-5.5"] +ollama-cloud = ["=deepseek-v4-pro"] +"#, + ) + .expect("write config"); + crate::config::Config::invalidate_cache(); + + assert!(provider_is_enabled("openai")); + assert!(provider_is_enabled("ollama-cloud")); + assert!(!provider_is_enabled("anthropic")); + + let routes = vec![ + crate::provider::ModelRoute { + model: "claude-opus-4-7".to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.5".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.5-mini".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-pro".to_string(), + provider: "ollama-cloud".to_string(), + api_method: "openai-compatible:ollama-cloud".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + ]; + + let filtered = filter_model_routes_by_allowlist("Jcode", routes); + let models: Vec<_> = filtered.iter().map(|route| route.model.as_str()).collect(); + assert_eq!(models, vec!["gpt-5.5", "deepseek-v4-pro"]); +} + #[test] fn auth_profile_env_application_flushes_stale_openrouter_catalog_state() { let _lock = crate::storage::lock_test_env(); diff --git a/src/server.rs b/src/server.rs index 714522807..27ffefe84 100644 --- a/src/server.rs +++ b/src/server.rs @@ -15,6 +15,7 @@ mod comm_control; mod comm_plan; mod comm_session; mod comm_sync; +mod config_reload; mod debug; mod debug_ambient; mod debug_command_exec; @@ -81,7 +82,7 @@ use crate::runtime_memory_log::{ }; use crate::tool::selfdev::ReloadContext; use crate::transport::Listener; -use anyhow::Result; +use anyhow::{Context, Result}; use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource}; use jcode_swarm_core::{ append_swarm_completion_report_instructions, format_structured_completion_report, @@ -913,6 +914,8 @@ impl Server { .await; }); + config_reload::spawn_config_reload_monitor(); + // Log when we receive SIGTERM for debugging #[cfg(unix)] { @@ -1695,11 +1698,13 @@ impl Server { pub async fn run(&self) -> Result<()> { // Ensure socket directory exists (for named sockets like /run/user/1000/jcode/) if let Some(parent) = self.socket_path.parent() { - std::fs::create_dir_all(parent)?; + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create socket directory {}", parent.display()) + })?; } #[cfg(unix)] - let _daemon_lock = acquire_daemon_lock()?; + let _daemon_lock = acquire_daemon_lock().context("failed to acquire daemon lock")?; if socket_has_live_listener(&self.socket_path).await { anyhow::bail!( @@ -1712,8 +1717,15 @@ impl Server { crate::transport::remove_socket(&self.socket_path); crate::transport::remove_socket(&self.debug_socket_path); - let main_listener = Listener::bind(&self.socket_path)?; - let debug_listener = Listener::bind(&self.debug_socket_path)?; + let main_listener = Listener::bind(&self.socket_path).with_context(|| { + format!("failed to bind main socket {}", self.socket_path.display()) + })?; + let debug_listener = Listener::bind(&self.debug_socket_path).with_context(|| { + format!( + "failed to bind debug socket {}", + self.debug_socket_path.display() + ) + })?; #[cfg(unix)] { diff --git a/src/server/client_lifecycle.rs b/src/server/client_lifecycle.rs index 688153297..718237c3e 100644 --- a/src/server/client_lifecycle.rs +++ b/src/server/client_lifecycle.rs @@ -357,6 +357,7 @@ async fn handle_lightweight_control_request( session_id: req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, } => { handle_comm_spawn( @@ -364,6 +365,7 @@ async fn handle_lightweight_control_request( req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, &client_event_tx, sessions, @@ -586,6 +588,7 @@ async fn handle_lightweight_control_request( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, } => { handle_comm_assign_next( @@ -595,6 +598,7 @@ async fn handle_lightweight_control_request( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, &client_event_tx, sessions, @@ -1198,6 +1202,17 @@ pub(super) async fn handle_client( }); } } + Ok(BusEvent::AskUserQuestionOpened(question)) => { + crate::logging::warn(&format!( + "[ask_user] server saw bus event request_id={} question_session={} client_session={}", + question.request_id, question.session_id, client_session_id + )); + if question.session_id == client_session_id { + let _ = client_event_tx.send( + ServerEvent::AskUserQuestionOpened { question }, + ); + } + } Ok(BusEvent::CompactionFinished) => { let agent = Arc::clone(&agent); let tx = client_event_tx.clone(); @@ -1483,6 +1498,15 @@ pub(super) async fn handle_client( } } + Request::SubmitAskUserAnswer { id, answer } => { + // Submit the user's answer to the pending registry. The tool + // task awaiting on the oneshot will wake and continue. Ack + // immediately; if no matching pending request exists we still + // ack (the tool may have timed out or been cancelled). + let _ = id; + crate::ask_user::submit_answer(answer); + } + Request::Ping { id } => { let json = encode_event(&ServerEvent::Pong { id }); let mut w = writer.lock().await; @@ -2191,6 +2215,7 @@ pub(super) async fn handle_client( session_id: req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, } => { handle_comm_spawn( @@ -2198,6 +2223,7 @@ pub(super) async fn handle_client( req_session_id, working_dir, initial_message, + spawn_mode, request_nonce, &client_event_tx, &sessions, @@ -2430,6 +2456,7 @@ pub(super) async fn handle_client( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, } => { handle_comm_assign_next( @@ -2439,6 +2466,7 @@ pub(super) async fn handle_client( working_dir, prefer_spawn, spawn_if_needed, + spawn_mode, message, &client_event_tx, &sessions, diff --git a/src/server/client_state.rs b/src/server/client_state.rs index f8c7ea4aa..d69732387 100644 --- a/src/server/client_state.rs +++ b/src/server/client_state.rs @@ -679,15 +679,17 @@ async fn write_event(writer: &Arc>, event: &ServerEvent) -> Res pub(super) fn spawn_model_prefetch_update(provider: Arc, agent: Arc>) { tokio::spawn(async move { - let (provider_name, initial_models) = { + let (provider_name, initial_models, initial_routes) = { let agent_guard = agent.lock().await; ( agent_guard.provider_name(), agent_guard.available_models_display(), + agent_guard.model_routes(), ) }; - if !initial_models.is_empty() { + if !initial_routes.is_empty() { + Bus::global().publish_models_updated(); return; } @@ -711,7 +713,7 @@ pub(super) fn spawn_model_prefetch_update(provider: Arc, agent: Ar ) }; - if refreshed.0 == initial_models && refreshed.1.is_empty() { + if refreshed.0 == initial_models && refreshed.1 == initial_routes { return; } diff --git a/src/server/comm_control.rs b/src/server/comm_control.rs index 0319fcbdf..f254198dd 100644 --- a/src/server/comm_control.rs +++ b/src/server/comm_control.rs @@ -1156,6 +1156,7 @@ pub(super) async fn handle_comm_assign_next( working_dir: Option, prefer_spawn: Option, spawn_if_needed: Option, + spawn_mode: Option, message: Option, client_event_tx: &mpsc::UnboundedSender, sessions: &SessionAgents, @@ -1216,6 +1217,7 @@ pub(super) async fn handle_comm_assign_next( &swarm_id, working_dir.clone(), None, + spawn_mode, sessions, global_session_id, provider_template, diff --git a/src/server/comm_control_tests/assign_next_dependency.rs b/src/server/comm_control_tests/assign_next_dependency.rs index 977996f4a..a16e103ff 100644 --- a/src/server/comm_control_tests/assign_next_dependency.rs +++ b/src/server/comm_control_tests/assign_next_dependency.rs @@ -67,6 +67,7 @@ async fn assign_next_prefers_worker_with_dependency_context() { None, None, None, + None, &client_tx, &sessions, &global_session_id, diff --git a/src/server/comm_control_tests/assign_next_metadata.rs b/src/server/comm_control_tests/assign_next_metadata.rs index 6565666f1..3ad904292 100644 --- a/src/server/comm_control_tests/assign_next_metadata.rs +++ b/src/server/comm_control_tests/assign_next_metadata.rs @@ -72,6 +72,7 @@ async fn assign_next_prefers_worker_with_matching_subsystem_metadata() { None, None, None, + None, &client_tx, &sessions, &global_session_id, diff --git a/src/server/comm_session.rs b/src/server/comm_session.rs index 71c9dc11c..e45ac03c6 100644 --- a/src/server/comm_session.rs +++ b/src/server/comm_session.rs @@ -12,7 +12,7 @@ use super::{ update_member_status, update_member_status_with_report, }; use crate::agent::Agent; -use crate::protocol::{NotificationType, ServerEvent}; +use crate::protocol::{CommSpawnMode, NotificationType, ServerEvent}; use crate::provider::Provider; use crate::session::Session; use std::collections::{HashMap, HashSet}; @@ -91,9 +91,11 @@ fn spawn_visible_session_window( selfdev_requested: bool, provider_key: Option<&str>, ) -> anyhow::Result { + // client_update_candidate prefers published channels, then repo dev binaries, + // then current_exe() (filtering out cargo test binaries). If none found, + // fall through to PATH-resolved "jcode". let exe = crate::build::client_update_candidate(selfdev_requested) .map(|(path, _label)| path) - .or_else(|| std::env::current_exe().ok()) .unwrap_or_else(|| PathBuf::from("jcode")); if selfdev_requested { crate::cli::tui_launch::spawn_selfdev_in_new_terminal_with_provider( @@ -142,6 +144,14 @@ fn provider_key_for_spawn_model( crate::provider::provider_for_model(model).map(str::to_string) } +fn spawn_mode_key(spawn_mode: Option) -> &'static str { + match spawn_mode.unwrap_or_default() { + CommSpawnMode::Visible => "visible", + CommSpawnMode::Headless => "headless", + CommSpawnMode::Auto => "auto", + } +} + fn persist_headed_startup_message(session_id: &str, message: &str) { crate::tui::App::save_startup_submission_for_session( session_id, @@ -291,6 +301,7 @@ pub(super) async fn spawn_swarm_agent( swarm_id: &str, working_dir: Option, initial_message: Option, + spawn_mode: Option, sessions: &SessionAgents, global_session_id: &Arc>, provider_template: &Arc, @@ -330,52 +341,63 @@ pub(super) async fn spawn_swarm_agent( .as_deref() .map(append_swarm_completion_report_instructions); - let visible_spawn = prepare_visible_spawn_session( - resolved_working_dir.as_deref(), - spawn_model.as_deref(), - spawn_provider_key.as_deref(), - coordinator_is_canary, - startup_message.as_deref(), - spawn_visible_session_window, - ); + let spawn_mode = spawn_mode.unwrap_or_default(); + let spawn_headless = || async { + let cmd = if let Some(ref dir) = resolved_working_dir { + format!("create_session:{dir}") + } else { + "create_session".to_string() + }; + create_headless_session( + sessions, + global_session_id, + provider_template, + &cmd, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + soft_interrupt_queues, + coordinator_is_canary, + spawn_model.clone(), + spawn_provider_key.clone(), + Some(Arc::clone(mcp_pool)), + Some(req_session_id.to_string()), + ) + .await + .and_then(|result_json| { + serde_json::from_str::(&result_json) + .ok() + .and_then(|value| { + value + .get("session_id") + .and_then(|session_id| session_id.as_str()) + .map(|session_id| session_id.to_string()) + }) + .map(|session_id| (session_id, true)) + .ok_or_else(|| anyhow::anyhow!("Failed to parse spawned session id")) + }) + }; - let (new_session_id, is_headless_fallback) = match visible_spawn { - Ok((new_session_id, true)) => Ok((new_session_id, false)), - Ok((_, false)) | Err(_) => { - let cmd = if let Some(ref dir) = resolved_working_dir { - format!("create_session:{dir}") - } else { - "create_session".to_string() - }; - create_headless_session( - sessions, - global_session_id, - provider_template, - &cmd, - swarm_members, - swarms_by_id, - swarm_coordinators, - swarm_plans, - soft_interrupt_queues, + let (new_session_id, is_headless_fallback) = match spawn_mode { + CommSpawnMode::Headless => spawn_headless().await, + CommSpawnMode::Visible | CommSpawnMode::Auto => { + let visible_spawn = prepare_visible_spawn_session( + resolved_working_dir.as_deref(), + spawn_model.as_deref(), + spawn_provider_key.as_deref(), coordinator_is_canary, - spawn_model.clone(), - spawn_provider_key.clone(), - Some(Arc::clone(mcp_pool)), - Some(req_session_id.to_string()), - ) - .await - .and_then(|result_json| { - serde_json::from_str::(&result_json) - .ok() - .and_then(|value| { - value - .get("session_id") - .and_then(|session_id| session_id.as_str()) - .map(|session_id| session_id.to_string()) - }) - .map(|session_id| (session_id, true)) - .ok_or_else(|| anyhow::anyhow!("Failed to parse spawned session id")) - }) + startup_message.as_deref(), + spawn_visible_session_window, + ); + + match visible_spawn { + Ok((new_session_id, true)) => Ok((new_session_id, false)), + Ok((_, false)) if spawn_mode == CommSpawnMode::Auto => spawn_headless().await, + Ok((_, false)) => Err(anyhow::anyhow!("Visible session launch was not available")), + Err(_error) if spawn_mode == CommSpawnMode::Auto => spawn_headless().await, + Err(error) => Err(error), + } } }?; @@ -510,6 +532,7 @@ pub(super) async fn handle_comm_spawn( req_session_id: String, working_dir: Option, initial_message: Option, + spawn_mode: Option, request_nonce: Option, client_event_tx: &mpsc::UnboundedSender, sessions: &SessionAgents, @@ -551,6 +574,7 @@ pub(super) async fn handle_comm_spawn( swarm_id.clone(), working_dir.clone().unwrap_or_default(), initial_message.clone().unwrap_or_default(), + spawn_mode_key(spawn_mode).to_string(), request_nonce.clone().unwrap_or_default(), ], ); @@ -572,6 +596,7 @@ pub(super) async fn handle_comm_spawn( &swarm_id, working_dir, initial_message, + spawn_mode, sessions, global_session_id, provider_template, diff --git a/src/server/config_reload.rs b/src/server/config_reload.rs new file mode 100644 index 000000000..459fddbfa --- /dev/null +++ b/src/server/config_reload.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +const CONFIG_RELOAD_POLL_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FileSignature { + modified: Option, + len: u64, +} + +type ConfigSnapshot = BTreeMap>; + +pub(super) fn spawn_config_reload_monitor() { + tokio::spawn(async move { + monitor_config_reload().await; + }); +} + +async fn monitor_config_reload() { + let mut previous = config_snapshot(); + let mut interval = tokio::time::interval(CONFIG_RELOAD_POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + let current = config_snapshot(); + if current == previous { + continue; + } + + crate::logging::info("Config change detected; triggering server reload"); + let request_id = + crate::server::send_reload_signal(env!("JCODE_GIT_HASH").to_string(), None, false); + crate::logging::info(&format!( + "Config reload signal queued with request_id={request_id}" + )); + previous = current; + + // A real reload replaces this process. In test/no-exec modes, avoid + // enqueueing duplicate reloads while filesystem timestamps settle. + tokio::time::sleep(CONFIG_RELOAD_POLL_INTERVAL).await; + } +} + +fn config_snapshot() -> ConfigSnapshot { + config_watch_paths() + .into_iter() + .map(|path| { + let signature = std::fs::metadata(&path).ok().map(|metadata| FileSignature { + modified: metadata.modified().ok(), + len: metadata.len(), + }); + (path, signature) + }) + .collect() +} + +fn config_watch_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(path) = crate::config::Config::path() { + paths.push(path); + } + + if let Ok(jcode_dir) = crate::storage::jcode_dir() { + paths.push(jcode_dir.join("mcp.json")); + } + + if let Ok(config_dir) = crate::storage::app_config_dir() + && let Ok(entries) = std::fs::read_dir(config_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("env") { + paths.push(path); + } + } + } + + paths.sort(); + paths.dedup(); + paths +} + +#[cfg(test)] +mod tests { + use super::*; + + struct EnvGuard { + key: &'static str, + old: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, old } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.old.take() { + crate::env::set_var(self.key, value); + } else { + crate::env::remove_var(self.key); + } + } + } + + #[test] + fn config_watch_paths_include_primary_config_mcp_and_env_files() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard::set("JCODE_HOME", temp.path()); + let config_dir = crate::storage::app_config_dir().expect("config dir"); + std::fs::create_dir_all(&config_dir).expect("create config dir"); + std::fs::write( + config_dir.join("opencode-go.env"), + "OPENCODE_GO_API_KEY=test\n", + ) + .expect("write env"); + std::fs::write(config_dir.join("cache.json"), "{}\n").expect("write cache"); + + let paths = config_watch_paths(); + + assert!(paths.contains(&temp.path().join("config.toml"))); + assert!(paths.contains(&temp.path().join("mcp.json"))); + assert!(paths.contains(&config_dir.join("opencode-go.env"))); + assert!(!paths.contains(&config_dir.join("cache.json"))); + } + + #[test] + fn config_snapshot_changes_when_watched_file_changes() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard::set("JCODE_HOME", temp.path()); + let config_path = temp.path().join("config.toml"); + std::fs::write(&config_path, "[provider]\n").expect("write config"); + + let before = config_snapshot(); + std::fs::write(&config_path, "[provider]\ndefault_model = \"gpt-5.5\"\n") + .expect("rewrite config"); + let after = config_snapshot(); + + assert_ne!(before, after); + } +} diff --git a/src/telemetry.rs b/src/telemetry.rs index c439e9336..e862373e4 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -787,6 +787,7 @@ fn post_payload(payload: serde_json::Value, timeout: Duration) -> bool { let client = match reqwest::blocking::Client::builder() .user_agent(crate::provider::JCODE_USER_AGENT) .timeout(timeout) + .no_proxy() .build() { Ok(client) => client, diff --git a/src/tool/apply_patch.rs b/src/tool/apply_patch.rs index 1f89a9d11..9de8d528d 100644 --- a/src/tool/apply_patch.rs +++ b/src/tool/apply_patch.rs @@ -1,5 +1,6 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -221,7 +222,12 @@ impl Tool for ApplyPatchTool { if results.is_empty() { Ok(ToolOutput::new("No changes applied")) } else { - let output = ToolOutput::new(results.join("\n")); + let resolved_touched_paths = touched_paths + .iter() + .map(|path| ctx.resolve_path(Path::new(path))) + .collect::>(); + let hygiene = run_post_edit_hygiene_for_paths(&ctx, &resolved_touched_paths).await; + let output = ToolOutput::new(format!("{}{}", results.join("\n"), hygiene)); if touched_paths.len() == 1 { Ok(output.with_title(touched_paths[0].clone())) } else { diff --git a/src/tool/ask_user_question.rs b/src/tool/ask_user_question.rs new file mode 100644 index 000000000..c5f0e8753 --- /dev/null +++ b/src/tool/ask_user_question.rs @@ -0,0 +1,315 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::ask_user::{AskUserAnswerKind, AskUserOption, AskUserQuestion, register_pending}; +use crate::bus::{Bus, BusEvent}; +use anyhow::{Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::time::Duration; + +/// Maximum time the tool will wait for the user to respond before giving up +/// and returning a "no response" tool result. Generous because we expect the +/// user to genuinely answer; the tool also returns early on Esc / disconnect. +const ASK_USER_TIMEOUT: Duration = Duration::from_secs(60 * 60); + +pub struct AskUserQuestionTool; + +impl AskUserQuestionTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct AskUserQuestionInput { + /// Short natural-language label for compact tool display. + #[serde(default)] + intent: Option, + /// The question to ask the user. + question: String, + /// Optional context shown above the choices. + #[serde(default)] + context: Option, + /// Candidate answers. Exactly one should normally be marked recommended. + options: Vec, + /// Allow the user to select more than one option. + #[serde(default)] + allow_multiple: bool, + /// Optional reply instructions / hint shown in the modal footer. + #[serde(default)] + reply_instructions: Option, + /// Optional modal title. + #[serde(default)] + title: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct QuestionOption { + /// Stable choice id shown to the user, such as `A`, `B`, `keep`, or `rec`. + #[serde(default)] + id: Option, + /// Human-readable option label. + label: String, + /// Optional exact value the agent should apply if this option is selected. + #[serde(default)] + value: Option, + /// Optional explanation/notes for this option. + #[serde(default)] + description: Option, + /// Whether this is the agent's recommended option. + #[serde(default)] + recommended: bool, + /// Why this option is recommended. Displayed only for recommended options. + #[serde(default)] + recommendation_reason: Option, +} + +#[async_trait] +impl Tool for AskUserQuestionTool { + fn name(&self) -> &str { + "askUserQuestion" + } + + fn description(&self) -> &str { + concat!( + "Ask the user an interactive multiple-choice question via a TUI modal overlay. ", + "Use this when user confirmation or preference selection would be clearer as a ", + "small set of choices rather than free-form chat. The user navigates with the ", + "arrow keys, presses Enter to pick an option, or selects \"Other\" to type a ", + "custom free-form answer. The tool blocks until the user responds or cancels. ", + "Mark exactly one option `recommended:true` when you have a preferred answer; ", + "the modal highlights it and pre-selects it for fast Enter-to-confirm." + ) + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["question", "options"], + "properties": { + "intent": super::intent_schema_property(), + "question": { + "type": "string", + "description": "The question to ask the user." + }, + "context": { + "type": "string", + "description": "Optional context shown above the choices." + }, + "options": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["label"], + "properties": { + "id": { + "type": "string", + "description": "Stable option id, e.g. A, B, keep, rec. Auto-generated as A/B/C if omitted." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + }, + "value": { + "type": "string", + "description": "Exact value the agent receives if this option is selected." + }, + "description": { + "type": "string", + "description": "Optional explanation shown under the option label." + }, + "recommended": { + "type": "boolean", + "description": "Whether this is the agent's recommended option. Prefer exactly one recommended option." + }, + "recommendation_reason": { + "type": "string", + "description": "Why this option is recommended." + } + } + } + }, + "allow_multiple": { + "type": "boolean", + "description": "Allow multiple options to be selected. Defaults to false." + }, + "reply_instructions": { + "type": "string", + "description": "Optional hint shown in the modal footer." + }, + "title": { + "type": "string", + "description": "Optional modal title. Defaults to Question." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: AskUserQuestionInput = serde_json::from_value(input)?; + if params.options.is_empty() { + bail!("askUserQuestion requires at least one option"); + } + + // Normalize options: assign auto ids where missing, capture parallel + // data for the response mapping. + let normalized: Vec = params + .options + .iter() + .enumerate() + .map(|(idx, option)| AskUserOption { + id: assigned_option_id(idx, option), + label: option.label.clone(), + description: option + .description + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + value: option + .value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + recommended: option.recommended, + recommendation_reason: option + .recommendation_reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + }) + .collect(); + + let request_id = format!( + "ask-user-{}-{}", + ctx.tool_call_id, + chrono::Utc::now().timestamp_millis() + ); + let receiver = register_pending(request_id.clone()); + + let question = AskUserQuestion { + request_id: request_id.clone(), + session_id: ctx.session_id.clone(), + question: params.question.clone(), + context: params + .context + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + options: normalized.clone(), + allow_multiple: params.allow_multiple, + reply_instructions: params + .reply_instructions + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + title: params + .title + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned), + }; + + crate::logging::warn(&format!( + "[ask_user] publishing AskUserQuestionOpened request_id={} session_id={} options={}", + request_id, + ctx.session_id, + question.options.len() + )); + Bus::global().publish(BusEvent::AskUserQuestionOpened(question)); + + let answer = match tokio::time::timeout(ASK_USER_TIMEOUT, receiver).await { + Ok(Ok(answer)) => answer, + Ok(Err(_recv_err)) => { + // Sender dropped (e.g. session reset) before answering. + return Ok(ToolOutput::new( + "User did not answer (modal was closed without selection).", + ) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "closed", + }))); + } + Err(_timeout) => { + // Try to clean up the pending entry to avoid leaking. + crate::ask_user::drop_pending(&request_id); + return Ok(ToolOutput::new( + "User did not answer within the timeout. Continue with a sensible default or ask again.", + ) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "timeout", + }))); + } + }; + + match &answer.kind { + AskUserAnswerKind::Options { ids, labels, values } => { + let pretty_choices = ids + .iter() + .zip(labels.iter()) + .map(|(id, label)| format!("{id} ({label})")) + .collect::>() + .join(", "); + let text = format!("User chose: {}", pretty_choices); + Ok(ToolOutput::new(text) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "selected", + "selected_ids": ids, + "selected_labels": labels, + "selected_values": values, + }))) + } + AskUserAnswerKind::Custom { text } => Ok(ToolOutput::new(format!( + "User typed a custom answer:\n{}", + text + )) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "custom", + "custom_text": text, + }))), + AskUserAnswerKind::Canceled => Ok(ToolOutput::new( + "User canceled the question (pressed Esc). Proceed without an answer or ask again with different framing.", + ) + .with_title("askUserQuestion") + .with_metadata(json!({ + "request_id": request_id, + "outcome": "canceled", + }))), + } + } +} + +fn assigned_option_id(idx: usize, option: &QuestionOption) -> String { + option + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| auto_option_id(idx)) +} + +fn auto_option_id(idx: usize) -> String { + if idx < 26 { + ((b'A' + idx as u8) as char).to_string() + } else { + (idx + 1).to_string() + } +} + +#[cfg(test)] +#[path = "ask_user_question_tests.rs"] +mod ask_user_question_tests; diff --git a/src/tool/ask_user_question_tests.rs b/src/tool/ask_user_question_tests.rs new file mode 100644 index 000000000..01c2bd357 --- /dev/null +++ b/src/tool/ask_user_question_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::ask_user::{AskUserAnswer, AskUserAnswerKind, submit_answer}; +use crate::bus::{Bus, BusEvent}; +use serde_json::json; + +fn unique_session_id(label: &str) -> String { + format!( + "ses_aq_{label}_{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) + ) +} + +fn test_ctx_with_session(session_id: String) -> ToolContext { + ToolContext { + session_id, + message_id: "msg1".to_string(), + tool_call_id: format!( + "tool_aq_{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) + ), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + } +} + +/// Wait for an `AskUserQuestionOpened` event matching `session_id`. Other +/// concurrent tests share the global bus, so we must filter by session to +/// avoid grabbing an unrelated request_id. +async fn wait_for_question( + rx: &mut tokio::sync::broadcast::Receiver, + session_id: &str, +) -> String { + loop { + match rx.recv().await { + Ok(BusEvent::AskUserQuestionOpened(q)) if q.session_id == session_id => { + return q.request_id; + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(e) => panic!("bus dropped before opening event: {e}"), + } + } +} + +#[tokio::test] +async fn rejects_empty_options() { + let tool = AskUserQuestionTool::new(); + let err = tool + .execute( + json!({ + "question": "Empty?", + "options": [] + }), + test_ctx_with_session(unique_session_id("reject")), + ) + .await + .expect_err("empty options should fail"); + assert!(err.to_string().contains("at least one option")); +} + +#[tokio::test] +async fn publishes_question_and_resolves_with_options_answer() { + let mut rx = Bus::global().subscribe(); + let tool = AskUserQuestionTool::new(); + let session_id = unique_session_id("opts"); + let session_id_for_exec = session_id.clone(); + + let exec = tokio::spawn(async move { + tool.execute( + json!({ + "question": "Pick one", + "options": [ + {"label": "Alpha"}, + {"label": "Beta", "recommended": true, "value": "beta-val"} + ], + }), + test_ctx_with_session(session_id_for_exec), + ) + .await + }); + + let request_id = wait_for_question(&mut rx, &session_id).await; + + let ok = submit_answer(AskUserAnswer { + request_id: request_id.clone(), + kind: AskUserAnswerKind::Options { + ids: vec!["B".into()], + labels: vec!["Beta".into()], + values: vec![Some("beta-val".into())], + }, + }); + assert!(ok, "submit_answer should succeed for known request"); + + let output = exec.await.expect("join").expect("tool execute"); + assert!( + output.output.contains("User chose: B (Beta)"), + "tool output did not include selection summary: {}", + output.output + ); + let metadata = output.metadata.expect("metadata"); + assert_eq!(metadata["outcome"], "selected"); + assert_eq!(metadata["selected_ids"][0], "B"); + assert_eq!(metadata["selected_values"][0], "beta-val"); +} + +#[tokio::test] +async fn custom_answer_reaches_tool_output() { + let mut rx = Bus::global().subscribe(); + let tool = AskUserQuestionTool::new(); + let session_id = unique_session_id("custom"); + let session_id_for_exec = session_id.clone(); + let exec = tokio::spawn(async move { + tool.execute( + json!({ + "question": "What now?", + "options": [{"label": "Whatever"}] + }), + test_ctx_with_session(session_id_for_exec), + ) + .await + }); + + let request_id = wait_for_question(&mut rx, &session_id).await; + let ok = submit_answer(AskUserAnswer { + request_id, + kind: AskUserAnswerKind::Custom { + text: "do the thing".into(), + }, + }); + assert!(ok); + + let output = exec.await.expect("join").expect("execute"); + assert!(output.output.contains("do the thing")); + let metadata = output.metadata.expect("metadata"); + assert_eq!(metadata["outcome"], "custom"); + assert_eq!(metadata["custom_text"], "do the thing"); +} + +#[tokio::test] +async fn canceled_answer_is_surfaced() { + let mut rx = Bus::global().subscribe(); + let tool = AskUserQuestionTool::new(); + let session_id = unique_session_id("cancel"); + let session_id_for_exec = session_id.clone(); + let exec = tokio::spawn(async move { + tool.execute( + json!({ + "question": "Anything?", + "options": [{"label": "Whatever"}] + }), + test_ctx_with_session(session_id_for_exec), + ) + .await + }); + + let request_id = wait_for_question(&mut rx, &session_id).await; + submit_answer(AskUserAnswer { + request_id, + kind: AskUserAnswerKind::Canceled, + }); + let output = exec.await.expect("join").expect("execute"); + assert!(output.output.contains("canceled")); + let metadata = output.metadata.expect("metadata"); + assert_eq!(metadata["outcome"], "canceled"); +} diff --git a/src/tool/code_hygiene.rs b/src/tool/code_hygiene.rs new file mode 100644 index 000000000..7d06b5a8f --- /dev/null +++ b/src/tool/code_hygiene.rs @@ -0,0 +1,211 @@ +use crate::tool::ToolContext; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; + +const POST_EDIT_HOOK_TIMEOUT: Duration = Duration::from_secs(20); + +pub(crate) async fn run_post_edit_hygiene_for_paths( + ctx: &ToolContext, + paths: &[PathBuf], +) -> String { + if std::env::var("JCODE_POST_EDIT_HOOKS") + .ok() + .is_some_and(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" + ) + }) + { + return String::new(); + } + + let mut reports = Vec::new(); + for path in paths { + if !path.is_file() || !looks_like_code_file(path) { + continue; + } + if let Some(report) = run_post_edit_hygiene_for_path(ctx, path).await { + reports.push(report); + } + } + + if reports.is_empty() { + String::new() + } else { + format!("\n\nPost-edit hygiene:\n{}", reports.join("\n")) + } +} + +fn looks_like_code_file(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some( + "rs" | "ts" + | "tsx" + | "js" + | "jsx" + | "mjs" + | "cjs" + | "py" + | "go" + | "json" + | "css" + | "scss" + | "html" + | "md" + | "yaml" + | "yml" + ) + ) +} + +async fn run_post_edit_hygiene_for_path(ctx: &ToolContext, path: &Path) -> Option { + let cwd = ctx + .working_dir + .clone() + .or_else(|| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")); + let display = path + .strip_prefix(&cwd) + .unwrap_or(path) + .display() + .to_string(); + let ext = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default(); + + let mut steps: Vec<(&str, Vec)> = Vec::new(); + match ext { + "rs" => { + steps.push(("format", vec!["rustfmt".into(), path.display().to_string()])); + if nearest_named_file(path, "Cargo.toml").is_some() { + steps.push(( + "typecheck", + vec!["cargo".into(), "check".into(), "-q".into()], + )); + } + } + "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "json" | "css" | "scss" | "html" | "md" + | "yaml" | "yml" => { + if nearest_named_file(path, "package.json").is_some() { + steps.push(( + "format", + vec![ + "npx".into(), + "--yes".into(), + "prettier".into(), + "--write".into(), + path.display().to_string(), + ], + )); + steps.push(( + "lint", + vec![ + "npx".into(), + "--yes".into(), + "eslint".into(), + path.display().to_string(), + ], + )); + } + } + "py" => { + steps.push(( + "format", + vec![ + "python3".into(), + "-m".into(), + "black".into(), + path.display().to_string(), + ], + )); + steps.push(( + "lint", + vec![ + "python3".into(), + "-m".into(), + "ruff".into(), + "check".into(), + path.display().to_string(), + ], + )); + } + "go" => { + steps.push(( + "format", + vec!["gofmt".into(), "-w".into(), path.display().to_string()], + )); + if nearest_named_file(path, "go.mod").is_some() { + steps.push(( + "typecheck", + vec!["go".into(), "test".into(), "./...".into()], + )); + } + } + _ => {} + } + + if steps.is_empty() { + return None; + } + + let mut outcomes = Vec::new(); + for (label, command) in steps { + let outcome = run_command(&cwd, command).await; + outcomes.push(format!("{} {}", label, outcome)); + } + + Some(format!("- `{}`: {}", display, outcomes.join("; "))) +} + +async fn run_command(cwd: &Path, command: Vec) -> String { + let rendered = command.join(" "); + let Some((program, args)) = command.split_first() else { + return "skipped: empty command".to_string(); + }; + + let mut cmd = Command::new(program); + cmd.args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + match timeout(POST_EDIT_HOOK_TIMEOUT, cmd.output()).await { + Ok(Ok(output)) if output.status.success() => format!("✓ `{}`", rendered), + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = first_nonempty_line(&stderr) + .or_else(|| first_nonempty_line(&stdout)) + .unwrap_or("no output") + .to_string(); + format!("✗ `{}` ({})", rendered, details) + } + Ok(Err(err)) => format!("skipped `{}` ({})", rendered, err), + Err(_) => format!( + "timed out `{}` after {}s", + rendered, + POST_EDIT_HOOK_TIMEOUT.as_secs() + ), + } +} + +fn first_nonempty_line(text: &str) -> Option<&str> { + text.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn nearest_named_file(path: &Path, name: &str) -> Option { + for ancestor in path.ancestors() { + let candidate = ancestor.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} diff --git a/src/tool/communicate.rs b/src/tool/communicate.rs index 6e5b58ff0..5df33f013 100644 --- a/src/tool/communicate.rs +++ b/src/tool/communicate.rs @@ -3,9 +3,9 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::plan::PlanItem; use crate::protocol::{ - AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, CommDeliveryMode, ContextEntry, - HistoryMessage, PlanGraphStatus, Request, ServerEvent, SwarmChannelInfo, ToolCallSummary, - comm_cleanup_candidate_session_ids, default_comm_await_target_statuses, + AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, CommDeliveryMode, CommSpawnMode, + ContextEntry, HistoryMessage, PlanGraphStatus, Request, ServerEvent, SwarmChannelInfo, + ToolCallSummary, comm_cleanup_candidate_session_ids, default_comm_await_target_statuses, default_comm_cleanup_target_statuses, default_comm_run_await_statuses, format_comm_awaited_members_with_reports, format_comm_channels, format_comm_context_entries, format_comm_context_history, format_comm_members, format_comm_plan_followup, @@ -256,6 +256,7 @@ async fn run_swarm_plan_to_terminal( working_dir: params.working_dir.clone(), prefer_spawn: params.prefer_spawn, spawn_if_needed, + spawn_mode: params.spawn_mode, message: params.message.clone(), }; match send_request(request).await { @@ -305,6 +306,7 @@ async fn spawn_assignment_session(ctx: &ToolContext, params: &CommunicateInput) session_id: ctx.session_id.clone(), working_dir: params.working_dir.clone(), initial_message: None, + spawn_mode: params.spawn_mode, request_nonce: Some(fresh_spawn_request_nonce(ctx)), }; @@ -489,6 +491,8 @@ struct CommunicateInput { #[serde(default)] spawn_if_needed: Option, #[serde(default)] + spawn_mode: Option, + #[serde(default)] prefer_spawn: Option, #[serde(default)] plan_items: Option>, @@ -608,6 +612,11 @@ impl Tool for CommunicateTool { "type": "boolean", "description": "For assign_task without an explicit target_session: if no reusable agent is available, spawn a fresh agent and retry the assignment automatically." }, + "spawn_mode": { + "type": "string", + "enum": ["visible", "headless", "auto"], + "description": "For spawn/assign_next/fill_slots/run_plan: visible opens a terminal, headless runs in-process, auto tries visible then falls back to headless." + }, "prefer_spawn": { "type": "boolean", "description": "For assign_task without an explicit target_session: prefer a fresh spawned agent even if reusable workers are available." @@ -959,6 +968,7 @@ impl Tool for CommunicateTool { session_id: ctx.session_id.clone(), working_dir: params.working_dir.clone(), initial_message: params.spawn_initial_message(), + spawn_mode: params.spawn_mode, request_nonce: None, }; @@ -1234,6 +1244,7 @@ impl Tool for CommunicateTool { working_dir: params.working_dir.clone(), prefer_spawn: params.prefer_spawn, spawn_if_needed: params.spawn_if_needed, + spawn_mode: params.spawn_mode, message: params.message.clone(), }; @@ -1282,6 +1293,7 @@ impl Tool for CommunicateTool { working_dir: params.working_dir.clone(), prefer_spawn: params.prefer_spawn, spawn_if_needed: params.spawn_if_needed, + spawn_mode: params.spawn_mode, message: params.message.clone(), }; diff --git a/src/tool/db_execute.rs b/src/tool/db_execute.rs new file mode 100644 index 000000000..f879b77bd --- /dev/null +++ b/src/tool/db_execute.rs @@ -0,0 +1,161 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::process::Stdio; +use tokio::process::Command as TokioCommand; + +const DB_EXECUTE_DESCRIPTION: &str = "Execute a SQL statement against the agent's local Postgres database. Statements run as a per-session role that owns the session's schema; agents cannot access other sessions' data. Use for CREATE TABLE, INSERT, UPDATE, DELETE, SELECT, DROP TABLE, etc. For queries that may return large results, limit with SQL clauses."; + +pub struct DbExecuteTool; + +impl DbExecuteTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct DbExecuteInput { + sql: String, +} + +/// Build a db-execute tool that scopes SQL to the agent's session schema. +/// The container and credentials are well-known localhost defaults. +fn agent_schema_name(session_id: &str) -> String { + // Sanitize: schema names must start with a letter or underscore, + // contain only lowercase letters, digits, and underscores, and be <= 63 chars. + let sanitized: String = session_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + // Ensure it starts with a letter + let prefixed = if sanitized.starts_with(|c: char| c.is_ascii_alphabetic()) { + sanitized + } else { + format!("a_{}", sanitized) + }; + // Truncate to 30 chars, then add "agent_" prefix (fits within 63-char limit) + let short: String = prefixed.chars().take(30).collect(); + format!("agent_{}", short) +} + +fn provision_role_and_schema_sql(schema: &str) -> String { + // Creates a NOLOGIN role for the session (if missing), grants it to + // jcode_agent, creates/owns the schema, and sets the effective role + // + search_path. All SQL from the agent runs as this per-session role, + // which owns its schema but has no USAGE on any other agent's schema. + format!( + "DO $$\n\ + BEGIN\n\ + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '{schema}') THEN\n\ + CREATE ROLE {schema} NOLOGIN;\n\ + END IF;\n\ + END\n\ + $$;\n\ + GRANT {schema} TO jcode_agent;\n\ + CREATE SCHEMA IF NOT EXISTS {schema} AUTHORIZATION {schema};\n\ + ALTER SCHEMA {schema} OWNER TO {schema};\n\ + SET ROLE {schema};\n\ + SET search_path TO {schema};" + ) +} + +#[async_trait] +impl Tool for DbExecuteTool { + fn name(&self) -> &str { + "db-execute" + } + + fn description(&self) -> &str { + DB_EXECUTE_DESCRIPTION + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["sql"], + "properties": { + "intent": super::intent_schema_property(), + "sql": { + "type": "string", + "description": "SQL statement to execute. Scoped to agent's schema." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: DbExecuteInput = serde_json::from_value(input)?; + let schema = agent_schema_name(&ctx.session_id); + + let full_sql = format!( + "{}\n{}", + provision_role_and_schema_sql(&schema), + params.sql.trim() + ); + + let result = run_psql(&full_sql).await?; + Ok(ToolOutput::new(result)) + } +} + +async fn run_psql(sql: &str) -> Result { + let mut child = TokioCommand::new("docker") + .args([ + "exec", + "-i", + "jcode-agent-db", + "psql", + "-U", + "jcode_agent", + "-d", + "jcode_agent_workspace", + "-v", + "ON_ERROR_STOP=1", + "-A", // unaligned output + "-t", // tuples only (no headers) + "-q", // quiet + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + // Write SQL to stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + stdin.write_all(sql.as_bytes()).await?; + stdin.write_all(b"\n").await?; + // stdin is dropped here, closing the pipe + } + + let output = child.wait_with_output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + if stdout.is_empty() && stderr.is_empty() { + Ok("OK".to_string()) + } else if stdout.is_empty() { + Ok(stderr) + } else { + Ok(stdout) + } + } else { + Err(anyhow::anyhow!( + "psql error (exit {}): {}\n{}", + output.status.code().unwrap_or(-1), + stderr, + stdout + )) + } +} diff --git a/src/tool/edit.rs b/src/tool/edit.rs index 17a235b13..3e9f269c1 100644 --- a/src/tool/edit.rs +++ b/src/tool/edit.rs @@ -1,5 +1,6 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -140,9 +141,11 @@ impl Tool for EditTool { let end_line = start_line + params.new_string.lines().count().saturating_sub(1); let context = extract_context(&new_content, start_line, end_line, 3); + let hygiene = run_post_edit_hygiene_for_paths(&ctx, &[path.to_path_buf()]).await; + Ok(ToolOutput::new(format!( - "Edited {}: replaced {} occurrence(s)\n{}\n\nContext after edit (lines {}-{}):\n{}", - params.file_path, occurrences, diff, context.0, context.1, context.2 + "Edited {}: replaced {} occurrence(s)\n{}\n\nContext after edit (lines {}-{}):\n{}{}", + params.file_path, occurrences, diff, context.0, context.1, context.2, hygiene )) .with_title(params.file_path.clone())) } diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 9ce518a8f..b6686c0c5 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -1,13 +1,16 @@ mod agentgrep; pub mod ambient; mod apply_patch; +mod ask_user_question; mod bash; mod batch; mod bg; mod browser; +mod code_hygiene; mod codesearch; mod communicate; mod conversation_search; +mod db_execute; mod debug_socket; mod edit; mod glob; @@ -29,6 +32,7 @@ mod side_panel; mod skill; mod task; mod todo; +pub mod tool_search; mod webfetch; mod websearch; mod write; @@ -127,6 +131,18 @@ impl Registry { "side_panel", side_panel::SidePanelTool::new, ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "askUserQuestion", + ask_user_question::AskUserQuestionTool::new, + ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "ToolSearch", + tool_search::ToolSearchTool::new, + ); Self::insert_tool_timed(&mut m, &mut timings, "edit", edit::EditTool::new); Self::insert_tool_timed( &mut m, @@ -186,6 +202,12 @@ impl Registry { Self::insert_tool_timed(&mut m, &mut timings, "gmail", gmail::GmailTool::new); Self::insert_tool_timed(&mut m, &mut timings, "schedule", ambient::ScheduleTool::new); Self::insert_tool_timed(&mut m, &mut timings, "selfdev", selfdev::SelfDevTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "db-execute", + db_execute::DbExecuteTool::new, + ); let nonzero: Vec = timings .iter() .filter(|(_, ms)| *ms > 0) @@ -295,6 +317,26 @@ impl Registry { tools.keys().cloned().collect() } + /// Resolve a specific subset of tools by registry key. Unknown names are + /// silently skipped. Used by ToolSearch to surface deferred tools. + pub async fn definitions_for_names(&self, names: &HashSet) -> Vec { + let tools = self.tools.read().await; + let mut defs: Vec = names + .iter() + .filter_map(|name| { + tools.get(name).map(|tool| { + let mut def = tool.to_definition(); + if def.name != *name { + def.name = name.clone(); + } + def + }) + }) + .collect(); + defs.sort_by(|a, b| a.name.cmp(&b.name)); + defs + } + /// Enable test mode for memory tools (isolated storage) /// Called when session is marked as debug pub async fn enable_memory_test_mode(&self) { diff --git a/src/tool/multiedit.rs b/src/tool/multiedit.rs index 7d856f988..c89ea9c44 100644 --- a/src/tool/multiedit.rs +++ b/src/tool/multiedit.rs @@ -1,4 +1,5 @@ use super::{Tool, ToolContext, ToolOutput}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -155,6 +156,7 @@ impl Tool for MultiEditTool { if !applied.is_empty() { output.push_str("\nDiff:\n"); output.push_str(&generate_diff_summary(&original_content, &content)); + output.push_str(&run_post_edit_hygiene_for_paths(&ctx, &[path.to_path_buf()]).await); } Ok(ToolOutput::new(output).with_title(params.file_path.clone())) diff --git a/src/tool/tool_search.rs b/src/tool/tool_search.rs new file mode 100644 index 000000000..0afa58241 --- /dev/null +++ b/src/tool/tool_search.rs @@ -0,0 +1,357 @@ +//! ToolSearch: deferred-tool discovery. +//! +//! Under Anthropic OAuth (Claude Code), only a fixed set of tools is advertised +//! up front. ToolSearch lets the model discover and unlock additional tools +//! from the local registry at runtime by querying with a natural-language +//! string. Unlocked tools are then included in the next API request's tool +//! list so the model can actually call them. + +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, OnceLock}; + +/// Session-scoped registry of tools that have been unlocked via ToolSearch. +/// +/// Keyed by `session_id`. The agent reads from this when assembling the tool +/// list for the next API request. +fn unlocked_store() -> &'static Mutex>> { + static STORE: OnceLock>>> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Tools that ToolSearch is allowed to surface. Excludes the always-on OAuth +/// hardcoded tools (those are already callable) and excludes internal tools +/// that shouldn't be model-callable directly. +/// +/// Names here are the registry keys (the names the model should use when +/// calling the tool). Descriptions and keywords are used for matching. +fn searchable_registry() -> Vec<(&'static str, &'static str, &'static str)> { + // (registry_name, short_description, search_keywords) + vec![ + ( + "askUserQuestion", + "Ask the user a structured multiple-choice question with a recommended option.", + "ask user question prompt choose confirm preference quiz options recommend", + ), + ( + "webfetch", + "Fetch a URL and return its contents.", + "fetch http url web download page content webfetch", + ), + ( + "websearch", + "Search the web and return results.", + "search web google find query results websearch", + ), + ( + "open", + "Open a file, URL, or application using the system handler.", + "open launch file url application reveal", + ), + ( + "todo", + "Manage a structured todo list for the current task.", + "todo task list plan steps checklist progress todowrite", + ), + ( + "batch", + "Run multiple tool calls in a single batched invocation.", + "batch parallel multiple tools group", + ), + ( + "patch", + "Apply a patch to a file using a structured diff.", + "patch diff apply file edit", + ), + ( + "multiedit", + "Perform multiple edits to one file in a single call.", + "multi edit multiple changes file replace multiedit", + ), + ( + "apply_patch", + "Apply a v4a-format patch across one or more files.", + "apply patch diff files multi-file change", + ), + ( + "lsp", + "Query the language server for symbols, references, diagnostics.", + "lsp language server symbol reference diagnostic definition hover", + ), + ( + "codesearch", + "Semantic code search over the workspace.", + "code search semantic find symbol function codesearch", + ), + ( + "conversation_search", + "Search past conversations and journal entries.", + "conversation search history past journal", + ), + ( + "side_panel", + "Create or update a side-panel page with rich markdown content.", + "side panel page markdown ui display sidepanel", + ), + ( + "memory", + "Read, write, and manage long-term memory entries.", + "memory remember recall note long term storage", + ), + ( + "goal", + "Manage long-running goals with milestones and checkpoints.", + "goal milestone checkpoint long task plan", + ), + ] +} + +/// Map a ToolSearch-surfaced name to the registry key. Currently a no-op +/// because ToolSearch surfaces registry keys directly, but kept as a hook for +/// future display-name vs registry-key divergence. +pub fn registry_key_for_search_name(name: &str) -> &str { + name +} + +/// Mark `tool_name` as unlocked for `session_id`. +pub fn unlock_tool(session_id: &str, tool_name: &str) { + let mut map = unlocked_store().lock().expect("unlocked tools mutex"); + map.entry(session_id.to_string()) + .or_default() + .insert(tool_name.to_string()); +} + +/// Get a snapshot of unlocked tools for `session_id` (registry-key form). +pub fn unlocked_for_session(session_id: &str) -> HashSet { + let map = unlocked_store().lock().expect("unlocked tools mutex"); + map.get(session_id).cloned().unwrap_or_default() +} + +/// Clear unlocked tools for `session_id` (e.g. on session reset). +#[allow(dead_code)] +pub fn clear_session(session_id: &str) { + let mut map = unlocked_store().lock().expect("unlocked tools mutex"); + map.remove(session_id); +} + +pub struct ToolSearchTool; + +impl ToolSearchTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct ToolSearchInput { + #[serde(default)] + intent: Option, + /// Free-text query, e.g. "ask the user a question" or "fetch a URL". + query: String, + /// Maximum number of results to return. Defaults to 5. + #[serde(default)] + max_results: Option, +} + +#[async_trait] +impl Tool for ToolSearchTool { + fn name(&self) -> &str { + "ToolSearch" + } + + fn description(&self) -> &str { + concat!( + "Fetches full schema definitions for deferred tools so they can be called. ", + "Use this when you need a capability beyond the always-available core tools ", + "(Bash, Read, Write, Edit, Glob, Grep, Agent, Skill, ScheduleWakeup). ", + "Returns matching tool names with their input schemas. ", + "After ToolSearch returns, the matched tools become callable on the next turn." + ) + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query", "max_results"], + "properties": { + "intent": super::intent_schema_property(), + "query": { + "type": "string", + "description": "Natural-language description of the capability you need." + }, + "max_results": { + "type": "number", + "description": "Maximum number of results to return.", + "default": 5 + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: ToolSearchInput = serde_json::from_value(input)?; + let max_results = params.max_results.unwrap_or(5).max(1).min(20); + let query = params.query.trim().to_lowercase(); + + let scored = score_matches(&query, &searchable_registry(), max_results); + + if scored.is_empty() { + return Ok(ToolOutput::new(format!( + "No deferred tools matched query: {:?}. Core tools (Bash, Read, Write, Edit, Glob, Grep, Agent, Skill, ScheduleWakeup) are always available.", + params.query + )) + .with_title("ToolSearch")); + } + + // Unlock matched tools for this session so the next API request + // includes them in the tools array. + let mut matched_summaries: Vec = Vec::with_capacity(scored.len()); + for entry in &scored { + let registry_key = registry_key_for_search_name(entry.name); + unlock_tool(&ctx.session_id, registry_key); + matched_summaries.push(json!({ + "name": entry.name, + "description": entry.description, + "score": entry.score, + })); + } + + let mut text = String::new(); + text.push_str(&format!( + "Found {} matching tool(s) for {:?}. These tools are now callable on subsequent turns:\n\n", + scored.len(), + params.query + )); + for entry in &scored { + text.push_str(&format!("- `{}` — {}\n", entry.name, entry.description)); + } + text.push_str("\nCall any of these tools by name in your next tool_use block."); + + Ok(ToolOutput::new(text) + .with_title("ToolSearch") + .with_metadata(json!({ + "query": params.query, + "matches": matched_summaries, + "unlocked_for_session": ctx.session_id, + }))) + } +} + +struct ScoredEntry { + name: &'static str, + description: &'static str, + score: i64, +} + +fn score_matches( + query: &str, + entries: &[(&'static str, &'static str, &'static str)], + max_results: usize, +) -> Vec { + let q_terms: Vec<&str> = query + .split(|c: char| !c.is_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + if q_terms.is_empty() { + return Vec::new(); + } + + let mut scored: Vec = entries + .iter() + .filter_map(|(name, desc, keywords)| { + let haystack = format!( + "{} {} {}", + name.to_lowercase(), + desc.to_lowercase(), + keywords.to_lowercase() + ); + let mut score: i64 = 0; + for term in &q_terms { + if haystack.contains(term) { + score += 10; + if name.to_lowercase().contains(term) { + score += 20; + } + } + } + if score > 0 { + Some(ScoredEntry { + name, + description: desc, + score, + }) + } else { + None + } + }) + .collect(); + + scored.sort_by(|a, b| b.score.cmp(&a.score).then(a.name.cmp(b.name))); + scored.truncate(max_results); + scored +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_ask_user_question_for_natural_language_queries() { + let entries = searchable_registry(); + let cases = [ + "ask the user a question", + "ask user question", + "prompt the user for confirmation", + "askuserquestion", + ]; + for q in cases { + let results = score_matches(&q.to_lowercase(), &entries, 5); + assert!( + results.iter().any(|r| r.name == "askUserQuestion"), + "query {:?} did not surface askUserQuestion (got {:?})", + q, + results.iter().map(|r| r.name).collect::>() + ); + } + } + + #[test] + fn finds_webfetch_for_fetch_query() { + let entries = searchable_registry(); + let results = score_matches("fetch a url", &entries, 5); + assert!(results.iter().any(|r| r.name == "webfetch")); + } + + #[test] + fn empty_query_returns_nothing() { + let entries = searchable_registry(); + let results = score_matches("", &entries, 5); + assert!(results.is_empty()); + } + + #[test] + fn unlock_and_read_roundtrip() { + let sid = "test-session-tool-search-unlock"; + clear_session(sid); + assert!(unlocked_for_session(sid).is_empty()); + unlock_tool(sid, "askUserQuestion"); + unlock_tool(sid, "webfetch"); + let set = unlocked_for_session(sid); + assert!(set.contains("askUserQuestion")); + assert!(set.contains("webfetch")); + clear_session(sid); + assert!(unlocked_for_session(sid).is_empty()); + } + + #[test] + fn registry_key_mapping_covers_all_entries() { + for (name, _, _) in searchable_registry() { + let key = registry_key_for_search_name(name); + assert!(!key.is_empty(), "no registry key for search name {}", name); + } + } +} diff --git a/src/tool/write.rs b/src/tool/write.rs index 223b09868..1c71dfb0f 100644 --- a/src/tool/write.rs +++ b/src/tool/write.rs @@ -1,5 +1,6 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use crate::tool::code_hygiene::run_post_edit_hygiene_for_paths; use anyhow::Result; use async_trait::async_trait; use serde::Deserialize; @@ -103,21 +104,24 @@ impl Tool for WriteTool { detail, })); + let hygiene = run_post_edit_hygiene_for_paths(&ctx, &[path.to_path_buf()]).await; + if existed { Ok(ToolOutput::new(format!( - "Updated {} ({} lines){}\n{}", + "Updated {} ({} lines){}\n{}{}", params.file_path, line_count, if diff.is_empty() { "" } else { ":" }, - diff + diff, + hygiene )) .with_title(params.file_path.clone())) } else { // For new files, show all lines as additions let diff = generate_diff_summary("", ¶ms.content); Ok(ToolOutput::new(format!( - "Created {} ({} lines):\n{}", - params.file_path, line_count, diff + "Created {} ({} lines):\n{}{}", + params.file_path, line_count, diff, hygiene )) .with_title(params.file_path.clone())) } diff --git a/src/tui/app.rs b/src/tui/app.rs index 38eea9b75..5ddfab8b5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -49,6 +49,7 @@ pub enum AppRuntimeMode { TestHarness, } +mod ask_user_modal_app; mod auth; mod auth_account_picker_saved_accounts; mod catchup; @@ -980,6 +981,11 @@ pub struct App { account_picker_overlay: Option>, /// Usage overlay (None = not visible) usage_overlay: Option>, + /// `askUserQuestion` modal overlay (None = not visible) + ask_user_overlay: Option>, + /// Outbound queue of ask-user answers to forward to the server on the + /// next remote tick. Populated synchronously from the modal key handler. + pending_ask_user_answers: Vec, /// Whether a usage refresh request is currently in flight. usage_report_refreshing: bool, /// Last time the passive overnight progress card polled its run files. diff --git a/src/tui/app/ask_user_modal_app.rs b/src/tui/app/ask_user_modal_app.rs new file mode 100644 index 000000000..da4a0773a --- /dev/null +++ b/src/tui/app/ask_user_modal_app.rs @@ -0,0 +1,95 @@ +//! `App` glue for the `askUserQuestion` modal overlay: open, dispatch keys, +//! and submit the picked answer back through `crate::ask_user`. + +use super::*; +use crate::ask_user::{AskUserAnswer, AskUserAnswerKind, AskUserQuestion, submit_answer}; +use crate::tui::ask_user_modal::{AskUserModal, AskUserModalOutcome}; +use crossterm::event::{KeyCode, KeyModifiers}; +use std::cell::RefCell; + +impl App { + /// Open the ask-user modal for `question`. If a modal is already open for + /// a different request_id, cancel the previous one so the new one can + /// proceed; this preserves the invariant that only one ask-user modal is + /// ever pending at a time and prevents stuck states. + pub(crate) fn open_ask_user_modal(&mut self, question: AskUserQuestion) { + if let Some(existing) = self.ask_user_overlay.take() { + let prev_request_id = existing.borrow().request_id().to_string(); + if prev_request_id != question.request_id { + let cancel = AskUserAnswer { + request_id: prev_request_id, + kind: AskUserAnswerKind::Canceled, + }; + self.pending_ask_user_answers.push(cancel.clone()); + submit_answer(cancel); + } + } + let modal = AskUserModal::from_question(question); + self.ask_user_overlay = Some(RefCell::new(modal)); + self.set_status_notice("Agent is asking you a question."); + } + + /// Dispatch a key while the ask-user modal is visible. Returns true if + /// the key was consumed. + pub(crate) fn handle_ask_user_modal_key( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> bool { + let outcome = { + let Some(cell) = self.ask_user_overlay.as_ref() else { + return false; + }; + let mut modal = cell.borrow_mut(); + modal.handle_key(code, modifiers) + }; + + match outcome { + AskUserModalOutcome::Continue => {} + AskUserModalOutcome::Done(answer) => { + self.ask_user_overlay = None; + // In remote-client mode the actual pending registry lives in + // the server process; queue the answer for the next tick to + // forward via Request::SubmitAskUserAnswer. We also call the + // local submit so in-process / test contexts work uniformly. + self.pending_ask_user_answers.push(answer.clone()); + submit_answer(answer); + self.clear_status_notice(); + } + } + true + } + + /// Render the ask-user modal overlay if visible. + #[allow(dead_code)] // direct render path; currently driven via TuiState trait + pub(crate) fn render_ask_user_modal(&self, frame: &mut ratatui::Frame) { + if let Some(cell) = self.ask_user_overlay.as_ref() { + cell.borrow().render(frame); + } + } + + /// Cancel and dismiss any active modal (used on session reset / cleanup). + #[allow(dead_code)] // not yet wired into session reset path + pub(crate) fn cancel_ask_user_modal(&mut self) { + if let Some(cell) = self.ask_user_overlay.take() { + let request_id = cell.borrow().request_id().to_string(); + let cancel = AskUserAnswer { + request_id, + kind: AskUserAnswerKind::Canceled, + }; + self.pending_ask_user_answers.push(cancel.clone()); + submit_answer(cancel); + } + } + + /// Drain queued answers that need to be forwarded to the server. + pub(crate) fn drain_pending_ask_user_answers( + &mut self, + ) -> Vec { + std::mem::take(&mut self.pending_ask_user_answers) + } + + pub(crate) fn ask_user_modal_visible(&self) -> bool { + self.ask_user_overlay.is_some() + } +} diff --git a/src/tui/app/conversation_state.rs b/src/tui/app/conversation_state.rs index 448987c97..94d5ae539 100644 --- a/src/tui/app/conversation_state.rs +++ b/src/tui/app/conversation_state.rs @@ -401,6 +401,10 @@ impl App { self.status_notice = Some((text.into(), Instant::now())); } + pub fn clear_status_notice(&mut self) { + self.status_notice = None; + } + pub(crate) fn set_remote_startup_phase(&mut self, phase: super::RemoteStartupPhase) { let changed = self.remote_startup_phase.as_ref() != Some(&phase); self.remote_startup_phase = Some(phase); diff --git a/src/tui/app/helpers.rs b/src/tui/app/helpers.rs index 0e232656b..a1d2aa7e0 100644 --- a/src/tui/app/helpers.rs +++ b/src/tui/app/helpers.rs @@ -36,9 +36,11 @@ pub(super) fn extract_bracketed_system_message(message: &str) -> Option } pub(super) fn launch_client_executable() -> PathBuf { + // client_update_candidate prefers published channels, then repo dev binaries, + // then current_exe() (filtering out cargo test binaries). If none found, + // fall through to PATH-resolved "jcode". crate::build::client_update_candidate(crate::cli::selfdev::client_selfdev_requested()) .map(|(path, _label)| path) - .or_else(|| std::env::current_exe().ok()) .unwrap_or_else(|| PathBuf::from("jcode")) } @@ -373,11 +375,7 @@ pub(super) fn mask_email(email: &str) -> String { /// Spawn a new terminal window that resumes a jcode session. /// Returns Ok(true) if a terminal was successfully launched, Ok(false) if no terminal found. fn resume_invocation_args(session_id: &str, socket: Option<&str>) -> Vec { - let mut args = vec![ - "--fresh-spawn".to_string(), - "--resume".to_string(), - session_id.to_string(), - ]; + let mut args = vec!["--resume".to_string(), session_id.to_string()]; if let Some(socket) = socket.filter(|s| !s.trim().is_empty()) { args.push("--socket".to_string()); args.push(socket.to_string()); diff --git a/src/tui/app/helpers_tests.rs b/src/tui/app/helpers_tests.rs index af86e0506..22875fd3a 100644 --- a/src/tui/app/helpers_tests.rs +++ b/src/tui/app/helpers_tests.rs @@ -96,7 +96,6 @@ fn resume_invocation_args_includes_socket_when_present() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), "ses_123".to_string(), "--socket".to_string(), @@ -108,14 +107,7 @@ fn resume_invocation_args_includes_socket_when_present() { #[test] fn resume_invocation_args_omits_blank_socket() { let args = resume_invocation_args("ses_123", Some(" ")); - assert_eq!( - args, - vec![ - "--fresh-spawn".to_string(), - "--resume".to_string(), - "ses_123".to_string() - ] - ); + assert_eq!(args, vec!["--resume".to_string(), "ses_123".to_string()]); } #[test] @@ -135,7 +127,6 @@ fn build_resume_command_uses_imported_jcode_session_for_claude_code() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), crate::import::imported_claude_code_session_id("claude-session-123") ] @@ -161,7 +152,6 @@ fn build_resume_command_uses_imported_jcode_session_for_codex() { assert_eq!( args, vec![ - "--fresh-spawn".to_string(), "--resume".to_string(), crate::import::imported_codex_session_id("codex-session-123") ] diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index eb5f88bd7..43e7fc5bb 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -132,7 +132,18 @@ impl App { let auth = crate::auth::AuthStatus::check_fast(); let mut routes = Vec::new(); - for model in self.provider.available_models_display() { + let route_catalog = crate::provider_catalog::filter_model_routes_by_allowlist( + self.provider.name(), + self.provider.model_routes(), + ); + if !route_catalog.is_empty() { + return route_catalog; + } + let display = crate::provider_catalog::filter_models_by_allowlist( + self.provider.name(), + self.provider.available_models_display(), + ); + for model in display { if !model.contains('/') && crate::provider::provider_for_model(&model) == Some("openai") { if auth.openai_has_oauth { @@ -384,6 +395,8 @@ impl App { let build = move || { let routes_started = std::time::Instant::now(); let routes = provider.model_routes(); + let routes = + crate::provider_catalog::filter_model_routes_by_allowlist(provider.name(), routes); let routes_ms = routes_started.elapsed().as_millis(); let _ = tx.send(Ok(ModelPickerRoutesResult { routes, routes_ms })); }; @@ -517,18 +530,8 @@ impl App { } }; - let routes = if routes.is_empty() && self.is_remote && current_model != "unknown" { - vec![crate::provider::ModelRoute { - model: current_model.clone(), - provider: self - .remote_provider_name - .clone() - .unwrap_or_else(|| "current".to_string()), - api_method: "current".to_string(), - available: true, - detail: "catalog still loading".to_string(), - cheapness: None, - }] + let routes = if routes.is_empty() && self.is_remote { + self.build_remote_config_model_routes_fallback(¤t_model) } else { routes }; @@ -902,9 +905,18 @@ impl App { } pub(super) fn build_remote_model_routes_fallback(&self) -> Vec { + let mut routes = + Self::build_remote_model_routes_for_entries(&self.remote_available_entries); + Self::append_remote_named_provider_routes(&mut routes, &self.remote_available_entries); + routes + } + + fn build_remote_model_routes_for_entries( + entries: &[String], + ) -> Vec { let auth = crate::auth::AuthStatus::check_fast(); let mut routes = Vec::new(); - for model in &self.remote_available_entries { + for model in entries { if !crate::provider::is_listable_model_name(model) { continue; } @@ -1045,7 +1057,10 @@ impl App { added_any = true; } - if Self::remote_model_should_offer_copilot_route(model) && !model.contains("[1m]") { + if crate::provider_catalog::provider_is_enabled("copilot") + && Self::remote_model_should_offer_copilot_route(model) + && !model.contains("[1m]") + { routes.push(crate::provider::build_copilot_route( model, auth.copilot == crate::auth::AuthState::Available @@ -1081,6 +1096,146 @@ impl App { routes } + fn build_remote_config_model_routes_fallback( + &self, + current_model: &str, + ) -> Vec { + let mut models = Vec::new(); + let mut push_model = |model: &str| { + let model = model.trim(); + if model.is_empty() + || model == "unknown" + || model.contains('*') + || !crate::provider::is_listable_model_name(model) + || models.iter().any(|existing| existing == model) + { + return; + } + models.push(model.to_string()); + }; + + push_model(current_model); + + let cfg = crate::config::config(); + if let Some(default_model) = cfg.provider.default_model.as_deref() { + let default_model = default_model + .strip_prefix("copilot:") + .unwrap_or(default_model) + .strip_prefix("cursor:") + .unwrap_or(default_model) + .strip_prefix("antigravity:") + .unwrap_or(default_model) + .split('@') + .next() + .unwrap_or(default_model); + push_model(default_model); + } + + for patterns in cfg.provider.model_allowlist.values() { + for pattern in patterns { + let pattern = pattern.trim(); + let model = pattern.strip_prefix('=').unwrap_or(pattern); + push_model(model); + } + } + + let mut routes = Self::build_remote_model_routes_for_entries(&models); + Self::append_remote_named_provider_routes(&mut routes, &models); + routes + } + + fn append_remote_named_provider_routes( + routes: &mut Vec, + models: &[String], + ) { + let cfg = crate::config::config(); + for (profile_name, profile) in &cfg.providers { + if !crate::provider_catalog::provider_is_enabled(profile_name) { + continue; + } + + let mut profile_models = Vec::new(); + if let Some(default_model) = profile.default_model.as_deref() { + Self::push_unique_model(&mut profile_models, default_model); + } + for model in &profile.models { + Self::push_unique_model(&mut profile_models, &model.id); + } + if let Some(allowlist_models) = cfg.provider.model_allowlist.get(profile_name) { + for model in allowlist_models { + Self::push_unique_model(&mut profile_models, model); + } + } + + let Some(api_base) = crate::provider_catalog::normalize_api_base(&profile.base_url) + else { + continue; + }; + let requires_key = profile + .requires_api_key + .unwrap_or_else(|| !crate::provider_catalog::api_base_uses_localhost(&api_base)); + let has_credentials = match profile.auth { + crate::config::NamedProviderAuth::None => true, + crate::config::NamedProviderAuth::Bearer + | crate::config::NamedProviderAuth::Header => { + !requires_key + || profile + .api_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || profile + .api_key_env + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|env_key| { + if let Some(env_file) = profile.env_file.as_deref() { + crate::provider_catalog::load_env_value_from_env_or_config( + env_key, env_file, + ) + } else { + std::env::var(env_key).ok() + } + }) + .map(|value| value.trim().to_string()) + .is_some_and(|value| !value.is_empty()) + } + }; + let api_method = format!("openai-compatible:{}", profile_name); + for model in models { + if !profile_models.iter().any(|candidate| candidate == model) { + continue; + } + if routes.iter().any(|route| { + route.model == *model + && route.provider == *profile_name + && route.api_method == api_method + }) { + continue; + } + routes.push(crate::provider::ModelRoute { + model: model.clone(), + provider: profile_name.clone(), + api_method: api_method.clone(), + available: has_credentials, + detail: api_base.clone(), + cheapness: None, + }); + } + } + } + + fn push_unique_model(models: &mut Vec, model: &str) { + let model = model.trim().strip_prefix('=').unwrap_or(model.trim()); + if !model.is_empty() + && crate::provider::is_listable_model_name(model) + && !models.iter().any(|existing| existing == model) + { + models.push(model.to_string()); + } + } + pub(super) fn remote_model_should_offer_copilot_route(model: &str) -> bool { Self::remote_openai_compatible_route_for_model(model).is_none() && (Self::remote_model_is_server_copilot_only(model) diff --git a/src/tui/app/input.rs b/src/tui/app/input.rs index 7fda59a79..a137795ba 100644 --- a/src/tui/app/input.rs +++ b/src/tui/app/input.rs @@ -1174,6 +1174,14 @@ pub(super) fn handle_modal_key( code: KeyCode, modifiers: KeyModifiers, ) -> Result { + // The ask-user modal is a blocking overlay; capture all keys while it + // is visible so the user can navigate, type, or cancel without other + // shortcuts interfering. + if app.ask_user_modal_visible() { + app.handle_ask_user_modal_key(code, modifiers); + return Ok(true); + } + if app.changelog_scroll.is_some() { app.handle_changelog_key(code)?; return Ok(true); diff --git a/src/tui/app/local.rs b/src/tui/app/local.rs index 0dd481a27..3ec3d76ee 100644 --- a/src/tui/app/local.rs +++ b/src/tui/app/local.rs @@ -226,6 +226,14 @@ pub(super) fn handle_bus_event( false } } + Ok(BusEvent::AskUserQuestionOpened(question)) => { + if question.session_id == app.session.id { + app.open_ask_user_modal(question); + true + } else { + false + } + } Ok(BusEvent::TodoUpdated(event)) => { if event.session_id == app.session.id { app.refresh_todos_view_now() diff --git a/src/tui/app/remote.rs b/src/tui/app/remote.rs index 42cec041c..34f306947 100644 --- a/src/tui/app/remote.rs +++ b/src/tui/app/remote.rs @@ -65,6 +65,13 @@ pub(super) async fn handle_tick(app: &mut App, remote: &mut RemoteConnection) -> app.maybe_capture_runtime_memory_heartbeat(); app.progress_mouse_scroll_animation(); needs_redraw |= dispatch_compacted_history_load(app, remote).await; + + // Forward any queued ask-user-modal answers to the server. The modal's + // synchronous key handler enqueues these so we don't need to await inside + // the input loop. + for answer in app.drain_pending_ask_user_answers() { + remote.submit_ask_user_answer(answer); + } if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); needs_redraw = true; diff --git a/src/tui/app/remote/key_handling.rs b/src/tui/app/remote/key_handling.rs index 759f1aaec..842381b9a 100644 --- a/src/tui/app/remote/key_handling.rs +++ b/src/tui/app/remote/key_handling.rs @@ -235,6 +235,16 @@ async fn handle_remote_key_internal( let mut modifiers = modifiers; ctrl_bracket_fallback_to_esc(&mut code, &mut modifiers); + // Ask-user modal is a blocking overlay: capture every key so the user + // can navigate / type / cancel before anything else interferes. + if app.ask_user_modal_visible() { + app.handle_ask_user_modal_key(code, modifiers); + for answer in app.drain_pending_ask_user_answers() { + remote.submit_ask_user_answer(answer); + } + return Ok(()); + } + if app.changelog_scroll.is_some() { return app.handle_changelog_key(code); } diff --git a/src/tui/app/remote/server_events.rs b/src/tui/app/remote/server_events.rs index 7a67ad241..289740e29 100644 --- a/src/tui/app/remote/server_events.rs +++ b/src/tui/app/remote/server_events.rs @@ -878,6 +878,14 @@ pub(in crate::tui::app) fn handle_server_event( app.set_side_panel_snapshot(snapshot); false } + ServerEvent::AskUserQuestionOpened { question } => { + crate::logging::warn(&format!( + "[ask_user] client received ServerEvent request_id={} session_id={}; opening modal", + question.request_id, question.session_id + )); + app.open_ask_user_modal(question); + true + } ServerEvent::SwarmStatus { members } => { if app.swarm_enabled { app.remote_swarm_members = members; diff --git a/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 39f32de65..d557f92ec 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -277,19 +277,6 @@ impl App { let skills = self.current_skills_snapshot(); push_skill_commands(&mut commands, &mut seen, &skills); - // Remote/minimal TUI clients can start with an empty local skill registry, - // while direct slash invocation reloads on miss. Mirror that behavior for - // autocomplete so project-local skills like `/optimization` are suggested - // before the user has activated them once. - let working_dir = self - .session - .working_dir - .as_deref() - .map(std::path::Path::new); - if let Ok(reloaded) = crate::skill::SkillRegistry::load_for_working_dir(working_dir) { - push_skill_commands(&mut commands, &mut seen, &reloaded); - } - commands } @@ -327,7 +314,11 @@ impl App { } } else { push_unique(&mut seen, &mut models, self.provider.model()); - for model in self.provider.available_models_display() { + let display = crate::provider_catalog::filter_models_by_allowlist( + self.provider.name(), + self.provider.available_models_display(), + ); + for model in display { push_unique(&mut seen, &mut models, model); } } diff --git a/src/tui/app/tests/remote_startup_input_01/part_01.rs b/src/tui/app/tests/remote_startup_input_01/part_01.rs index b3bf528dc..27c23995e 100644 --- a/src/tui/app/tests/remote_startup_input_01/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_01/part_01.rs @@ -813,24 +813,98 @@ fn test_remote_model_switch_failure_restores_deferred_prompt() { #[test] fn test_model_picker_remote_falls_back_to_current_model_when_catalog_empty() { - let mut app = create_test_app(); - app.is_remote = true; - app.remote_provider_name = Some("openrouter".to_string()); - app.remote_provider_model = Some("anthropic/claude-sonnet-4".to_string()); - app.remote_available_entries.clear(); - app.remote_model_options.clear(); + with_temp_jcode_home(|| { + crate::config::invalidate_config_cache(); - app.open_model_picker(); + let mut app = create_test_app(); + app.is_remote = true; + app.remote_provider_name = Some("openrouter".to_string()); + app.remote_provider_model = Some("anthropic/claude-sonnet-4".to_string()); + app.remote_available_entries.clear(); + app.remote_model_options.clear(); + + app.open_model_picker(); + + let picker = app + .inline_interactive_state + .as_ref() + .expect("model picker should open with current-model fallback"); + + assert_eq!(picker.entries.len(), 1); + assert_eq!(picker.entries[0].name, "anthropic/claude-sonnet-4"); + assert_eq!(picker.entries[0].options.len(), 1); + assert_eq!(picker.entries[0].options[0].provider, "auto"); + assert_eq!(picker.entries[0].options[0].api_method, "openrouter"); + assert_ne!(picker.entries[0].options[0].detail, "catalog still loading"); + }); +} - let picker = app - .inline_interactive_state - .as_ref() - .expect("model picker should open with current-model fallback"); - - assert_eq!(picker.entries.len(), 1); - assert_eq!(picker.entries[0].name, "anthropic/claude-sonnet-4"); - assert_eq!(picker.entries[0].options.len(), 1); - assert_eq!(picker.entries[0].options[0].provider, "openrouter"); - assert_eq!(picker.entries[0].options[0].api_method, "current"); - assert!(picker.entries[0].options[0].available); +#[test] +fn test_model_picker_remote_falls_back_to_named_openai_compatible_profile() { + with_temp_jcode_home(|| { + let config_path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(config_path.parent().expect("config parent")) + .expect("create config dir"); + std::fs::write( + &config_path, + r#" +[provider.model_allowlist] +ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] + +[providers.ollama-cloud] +type = "openai-compatible" +base_url = "http://localhost:11434/v1" +auth = "none" +default_model = "deepseek-v4-pro" +models = [ + { id = "deepseek-v4-pro", context_window = 1000000 }, + { id = "deepseek-v4-flash", context_window = 1000000 }, +] +"#, + ) + .expect("write config"); + crate::config::invalidate_config_cache(); + assert!( + crate::config::config() + .providers + .contains_key("ollama-cloud"), + "test config should include ollama-cloud profile" + ); + + let mut app = create_test_app(); + app.is_remote = true; + app.remote_provider_name = Some("ollama-cloud".to_string()); + app.remote_provider_model = Some("deepseek-v4-pro".to_string()); + app.remote_available_entries = vec![ + "deepseek-v4-pro".to_string(), + "deepseek-v4-flash".to_string(), + ]; + app.remote_model_options.clear(); + + app.open_model_picker(); + + let picker = app + .inline_interactive_state + .as_ref() + .expect("model picker should open with named provider fallback"); + assert!( + picker.entries.iter().any(|entry| { + entry.name == "deepseek-v4-pro" + && entry.options.iter().any(|option| { + option.provider == "ollama-cloud" + && option.api_method == "openai-compatible:ollama-cloud" + && option.available + }) + }), + "expected ollama-cloud route, got: {:?}", + picker.entries + ); + assert!( + picker + .entries + .iter() + .flat_map(|entry| entry.options.iter()) + .all(|option| option.detail != "catalog still loading") + ); + }); } diff --git a/src/tui/app/tests/state_model_poke_03.rs b/src/tui/app/tests/state_model_poke_03.rs index 90fd5ac03..3b3f2c8d7 100644 --- a/src/tui/app/tests/state_model_poke_03.rs +++ b/src/tui/app/tests/state_model_poke_03.rs @@ -70,6 +70,11 @@ struct MixedModelRoutesProvider { model: StdArc>, } +#[derive(Clone)] +struct SubscriptionModelRoutesProvider { + model: StdArc>, +} + #[derive(Clone)] struct AuthUxStateSpaceProvider { authed: StdArc, @@ -184,6 +189,93 @@ impl MixedModelRoutesProvider { } } +impl SubscriptionModelRoutesProvider { + fn routes() -> Vec { + vec![ + crate::provider::ModelRoute { + model: "copilot-gpt-5".to_string(), + provider: "Copilot".to_string(), + api_method: "copilot".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "claude-opus-4-7[1m]".to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "claude-sonnet-4-6[1m]".to_string(), + provider: "Anthropic".to_string(), + api_method: "claude-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.5".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.4".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "gpt-5.3-codex-spark".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-oauth".to_string(), + available: true, + detail: String::new(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-pro".to_string(), + provider: "OpenCode Go".to_string(), + api_method: "openai-compatible:opencode-go".to_string(), + available: true, + detail: "https://opencode.ai/zen/go/v1".to_string(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-flash".to_string(), + provider: "OpenCode Go".to_string(), + api_method: "openai-compatible:opencode-go".to_string(), + available: true, + detail: "https://opencode.ai/zen/go/v1".to_string(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-pro".to_string(), + provider: "ollama-cloud".to_string(), + api_method: "openai-compatible:ollama-cloud".to_string(), + available: true, + detail: "https://ollama.com/v1".to_string(), + cheapness: None, + }, + crate::provider::ModelRoute { + model: "deepseek-v4-flash".to_string(), + provider: "ollama-cloud".to_string(), + api_method: "openai-compatible:ollama-cloud".to_string(), + available: true, + detail: "https://ollama.com/v1".to_string(), + cheapness: None, + }, + ] + } +} + #[async_trait::async_trait] impl Provider for AuthUxStateSpaceProvider { async fn complete( @@ -303,6 +395,48 @@ impl Provider for MixedModelRoutesProvider { } } +#[async_trait::async_trait] +impl Provider for SubscriptionModelRoutesProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + unimplemented!("SubscriptionModelRoutesProvider") + } + + fn name(&self) -> &str { + "OpenRouter" + } + + fn model(&self) -> String { + self.model.lock().unwrap().clone() + } + + fn available_models_display(&self) -> Vec { + crate::provider::listable_model_names_from_routes(&Self::routes()) + } + + fn model_routes(&self) -> Vec { + Self::routes() + } + + fn set_model(&self, model: &str) -> Result<()> { + let found = Self::routes().iter().any(|route| route.model == model); + if !found { + anyhow::bail!("model {model} is not available in the subscription catalog"); + } + *self.model.lock().unwrap() = model.to_string(); + Ok(()) + } + + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + #[async_trait::async_trait] impl Provider for EmptyPostLoginCatalogProvider { async fn complete( @@ -1071,6 +1205,111 @@ fn test_model_picker_state_space_preserves_provider_labels_after_route_hydration ); } +#[test] +fn test_model_picker_keeps_subscription_routes_under_openrouter_profile_allowlist() { + with_temp_jcode_home(|| { + let config_path = crate::storage::jcode_dir() + .expect("temp jcode home") + .join("config.toml"); + std::fs::write( + config_path, + r#" +[provider] +default_provider = "ollama-cloud" +default_model = "deepseek-v4-pro" +enabled_providers = ["anthropic", "openai", "opencode-go", "ollama-cloud"] + +[provider.model_allowlist] +anthropic = ["=claude-opus-4-7[1m]", "=claude-sonnet-4-6[1m]"] +openai = ["=gpt-5.5", "=gpt-5.4", "=gpt-5.3-codex-spark"] +opencode-go = ["=deepseek-v4-pro", "=deepseek-v4-flash"] +ollama-cloud = ["=deepseek-v4-pro", "=deepseek-v4-flash"] +"#, + ) + .expect("write temp config"); + crate::config::invalidate_config_cache(); + clear_persisted_test_ui_state(); + crate::tui::ui::clear_test_render_state_for_tests(); + + let provider: Arc = Arc::new(SubscriptionModelRoutesProvider { + model: StdArc::new(StdMutex::new("deepseek-v4-pro".to_string())), + }); + let rt = tokio::runtime::Runtime::new().unwrap(); + let registry = rt.block_on(crate::tool::Registry::new(provider.clone())); + let mut app = App::new_for_test_harness(provider, registry); + app.queue_mode = false; + app.diff_mode = crate::config::DiffDisplayMode::Inline; + + app.open_model_picker(); + wait_for_model_picker_load(&mut app); + + let picker = app + .inline_interactive_state + .as_ref() + .expect("subscription model picker should be open"); + let route_pairs: Vec<(String, String, String)> = picker + .entries + .iter() + .flat_map(|entry| { + entry.options.iter().map(|route| { + ( + entry.name.clone(), + route.provider.clone(), + route.api_method.clone(), + ) + }) + }) + .collect(); + + for expected in [ + "claude-opus-4-7[1m]", + "claude-sonnet-4-6[1m]", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex-spark", + "deepseek-v4-pro", + "deepseek-v4-flash", + ] { + assert!( + route_pairs.iter().any(|(model, _, _)| model == expected), + "missing {expected} from picker routes: {:?}", + route_pairs + ); + } + + assert!( + route_pairs.iter().any(|(model, provider, method)| { + model == "deepseek-v4-pro" + && provider == "ollama-cloud" + && method == "openai-compatible:ollama-cloud" + }), + "ollama-cloud Pro route should remain available: {:?}", + route_pairs + ); + assert!( + route_pairs.iter().any(|(model, provider, method)| { + model == "deepseek-v4-pro" + && provider == "OpenCode Go" + && method == "openai-compatible:opencode-go" + }), + "opencode-go Pro route should remain available: {:?}", + route_pairs + ); + assert!( + route_pairs.iter().all(|(_, provider, _)| provider != "opencode-go"), + "known provider ids should render with display labels: {:?}", + route_pairs + ); + assert!( + route_pairs.iter().all(|(_, provider, _)| provider != "Copilot"), + "Copilot routes should be hidden when Copilot is not enabled: {:?}", + route_pairs + ); + + crate::config::invalidate_config_cache(); + }); +} + #[test] fn test_model_picker_does_not_cache_single_model_fallback() { ensure_test_jcode_home_if_unset(); diff --git a/src/tui/app/tui_lifecycle.rs b/src/tui/app/tui_lifecycle.rs index bf5700224..9509ce084 100644 --- a/src/tui/app/tui_lifecycle.rs +++ b/src/tui/app/tui_lifecycle.rs @@ -542,6 +542,8 @@ impl App { login_picker_overlay: None, account_picker_overlay: None, usage_overlay: None, + ask_user_overlay: None, + pending_ask_user_answers: Vec::new(), usage_report_refreshing: false, last_overnight_card_refresh: None, }; @@ -907,6 +909,8 @@ impl App { login_picker_overlay: None, account_picker_overlay: None, usage_overlay: None, + ask_user_overlay: None, + pending_ask_user_answers: Vec::new(), usage_report_refreshing: false, last_overnight_card_refresh: None, }; diff --git a/src/tui/app/tui_state.rs b/src/tui/app/tui_state.rs index bbc82224d..f4e78598c 100644 --- a/src/tui/app/tui_state.rs +++ b/src/tui/app/tui_state.rs @@ -1260,6 +1260,10 @@ impl crate::tui::TuiState for App { self.usage_overlay.as_ref() } + fn ask_user_overlay(&self) -> Option<&RefCell> { + self.ask_user_overlay.as_ref() + } + fn working_dir(&self) -> Option { self.session.working_dir.clone() } diff --git a/src/tui/ask_user_modal.rs b/src/tui/ask_user_modal.rs new file mode 100644 index 000000000..8bf212836 --- /dev/null +++ b/src/tui/ask_user_modal.rs @@ -0,0 +1,868 @@ +//! TUI modal overlay for the `askUserQuestion` tool. +//! +//! Renders a centered overlay with: +//! - The question (and optional context) at the top +//! - A list of options the user navigates with arrow keys / j-k +//! - A pre-selected recommended option (if any) for quick Enter-to-confirm +//! - A final synthetic "Other (type custom answer)" entry that switches the +//! modal into a text-input mode where the user types a free-form reply +//! - Esc cancels and submits an `AskUserAnswerKind::Canceled` answer +//! +//! All state needed to fulfil the pending oneshot lives in the modal itself +//! plus the `request_id`. When the modal closes via Enter/Esc the host App +//! calls [`AskUserModal::take_pending_answer`] and submits it to the +//! `crate::ask_user` registry. +//! +//! ## Multi-select +//! When `allow_multiple` is true, Space toggles individual options on/off and +//! Enter submits the accumulated set; otherwise Enter directly submits the +//! single highlighted option (matching the common "pick one" pattern). + +use crate::ask_user::{AskUserAnswer, AskUserAnswerKind, AskUserOption, AskUserQuestion}; +use crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +const PANEL_BG: Color = Color::Rgb(24, 28, 40); +const PANEL_BORDER: Color = Color::Rgb(120, 140, 190); +const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); +const SELECTED_BG: Color = Color::Rgb(38, 42, 56); +const SELECTED_BG_RECOMMENDED: Color = Color::Rgb(38, 56, 50); +const RECOMMENDED_FG: Color = Color::Rgb(120, 230, 170); +const MUTED: Color = Color::Rgb(140, 146, 163); +const MUTED_DARK: Color = Color::Rgb(100, 106, 122); +const OPTION_FG: Color = Color::Rgb(220, 225, 240); +const CUSTOM_HINT_FG: Color = Color::Rgb(190, 170, 240); + +const OVERLAY_PERCENT_X: u16 = 70; +const OVERLAY_MAX_WIDTH: u16 = 84; +const OVERLAY_MIN_WIDTH: u16 = 44; +const OVERLAY_MIN_HEIGHT: u16 = 14; +const CONTENT_PAD_X: u16 = 2; + +/// What the modal wants the host App to do after handling a key. +pub enum AskUserModalOutcome { + /// Modal stays open; redraw. + Continue, + /// Modal should be removed and the contained answer submitted to the + /// `crate::ask_user` registry. + Done(AskUserAnswer), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Arrow-key navigation over the option list (and the synthetic Other row). + Choosing, + /// Free-form text input for the user's custom answer. + Typing, +} + +pub struct AskUserModal { + request_id: String, + title: String, + question: String, + context: Option, + options: Vec, + /// Whether more than one option may be picked simultaneously. + allow_multiple: bool, + /// Footer hint shown beneath the option list. + reply_instructions: Option, + /// Index of the focused row. Indices `0..options.len()` map to options; + /// `options.len()` is the synthetic "Other" row. + cursor: usize, + /// Picked options when `allow_multiple` is true (set of indices). + picked: Vec, + mode: Mode, + /// Free-form custom answer buffer when `mode == Typing`. + typed: String, +} + +impl AskUserModal { + pub fn from_question(question: AskUserQuestion) -> Self { + let picked = vec![false; question.options.len()]; + // Default the cursor to the first recommended option if any, else 0. + let recommended_idx = question + .options + .iter() + .position(|opt| opt.recommended) + .unwrap_or(0); + Self { + request_id: question.request_id, + title: question.title.unwrap_or_else(|| "Question".to_string()), + question: question.question, + context: question.context, + options: question.options, + allow_multiple: question.allow_multiple, + reply_instructions: question.reply_instructions, + cursor: recommended_idx, + picked, + mode: Mode::Choosing, + typed: String::new(), + } + } + + pub fn request_id(&self) -> &str { + &self.request_id + } + + /// Index of the synthetic "Other" row. + fn other_row(&self) -> usize { + self.options.len() + } + + /// Total number of navigable rows including "Other". + fn rows(&self) -> usize { + self.options.len() + 1 + } + + fn move_cursor(&mut self, delta: isize) { + let n = self.rows() as isize; + if n == 0 { + return; + } + let mut next = self.cursor as isize + delta; + if next < 0 { + next += n; + } + next %= n; + self.cursor = next as usize; + } + + fn build_options_answer(&self) -> AskUserAnswerKind { + let mut ids = Vec::new(); + let mut labels = Vec::new(); + let mut values = Vec::new(); + + if self.allow_multiple { + for (idx, picked) in self.picked.iter().enumerate() { + if *picked && idx < self.options.len() { + let opt = &self.options[idx]; + ids.push(opt.id.clone()); + labels.push(opt.label.clone()); + values.push(opt.value.clone()); + } + } + // Fallback: if user pressed Enter without toggling anything, treat + // the current row as the single selection. + if ids.is_empty() && self.cursor < self.options.len() { + let opt = &self.options[self.cursor]; + ids.push(opt.id.clone()); + labels.push(opt.label.clone()); + values.push(opt.value.clone()); + } + } else if self.cursor < self.options.len() { + let opt = &self.options[self.cursor]; + ids.push(opt.id.clone()); + labels.push(opt.label.clone()); + values.push(opt.value.clone()); + } + + AskUserAnswerKind::Options { + ids, + labels, + values, + } + } + + /// Process a keystroke and report the resulting modal outcome. + pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> AskUserModalOutcome { + if matches!(self.mode, Mode::Typing) { + return self.handle_key_typing(code, modifiers); + } + self.handle_key_choosing(code, modifiers) + } + + fn handle_key_choosing( + &mut self, + code: KeyCode, + _modifiers: KeyModifiers, + ) -> AskUserModalOutcome { + match code { + KeyCode::Esc => AskUserModalOutcome::Done(AskUserAnswer { + request_id: self.request_id.clone(), + kind: AskUserAnswerKind::Canceled, + }), + KeyCode::Up | KeyCode::Char('k') => { + self.move_cursor(-1); + AskUserModalOutcome::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_cursor(1); + AskUserModalOutcome::Continue + } + KeyCode::Home | KeyCode::Char('g') => { + self.cursor = 0; + AskUserModalOutcome::Continue + } + KeyCode::End | KeyCode::Char('G') => { + self.cursor = self.rows().saturating_sub(1); + AskUserModalOutcome::Continue + } + // Space toggles in multi-select mode (and is a no-op otherwise). + KeyCode::Char(' ') if self.allow_multiple && self.cursor < self.options.len() => { + let flipped = !self.picked[self.cursor]; + self.picked[self.cursor] = flipped; + AskUserModalOutcome::Continue + } + // Tab also moves down for quick navigation parity with form widgets. + KeyCode::Tab => { + self.move_cursor(1); + AskUserModalOutcome::Continue + } + KeyCode::BackTab => { + self.move_cursor(-1); + AskUserModalOutcome::Continue + } + KeyCode::Enter => { + if self.cursor == self.other_row() { + // Switch to free-form text input. + self.mode = Mode::Typing; + self.typed.clear(); + AskUserModalOutcome::Continue + } else { + AskUserModalOutcome::Done(AskUserAnswer { + request_id: self.request_id.clone(), + kind: self.build_options_answer(), + }) + } + } + // Quick-select by typing the option id letter when ids are A,B,C,... + KeyCode::Char(c) if c.is_ascii_alphanumeric() => { + let needle = c.to_ascii_uppercase().to_string(); + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.id.eq_ignore_ascii_case(&needle)) + { + self.cursor = idx; + if self.allow_multiple { + // Toggle when multi-select; otherwise the user still + // needs to press Enter to confirm. + self.picked[idx] = !self.picked[idx]; + } + } + AskUserModalOutcome::Continue + } + _ => AskUserModalOutcome::Continue, + } + } + + fn handle_key_typing(&mut self, code: KeyCode, modifiers: KeyModifiers) -> AskUserModalOutcome { + match code { + KeyCode::Esc => { + // Bail back to choosing without discarding typed text so the + // user can return and finish if Esc was a slip. + self.mode = Mode::Choosing; + self.cursor = self.other_row(); + AskUserModalOutcome::Continue + } + KeyCode::Enter => { + let text = self.typed.trim(); + if text.is_empty() { + // Disallow empty submissions: keep modal open. + return AskUserModalOutcome::Continue; + } + AskUserModalOutcome::Done(AskUserAnswer { + request_id: self.request_id.clone(), + kind: AskUserAnswerKind::Custom { + text: text.to_string(), + }, + }) + } + KeyCode::Backspace => { + self.typed.pop(); + AskUserModalOutcome::Continue + } + KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { + self.typed.push(c); + AskUserModalOutcome::Continue + } + _ => AskUserModalOutcome::Continue, + } + } + + pub fn render(&self, frame: &mut Frame) { + let area = centered_rect(frame.area()); + self.render_into(frame, area, true); + } + + /// Render the modal into a host-supplied rect without centering. The host + /// is responsible for laying out the area (typically the chat input slot) + /// so the modal can replace it inline, Claude-Code style. + pub fn render_inline(&self, frame: &mut Frame, area: Rect) { + self.render_into(frame, area, false); + } + + /// Conservative estimate of the rows the modal wants. The host can use + /// this to reserve an input chunk tall enough to fit question + context + + /// divider + options (+ descriptions / recommendation reasons) + footer + /// hint + typing pane. + pub fn desired_height(&self) -> u16 { + // We don't know the exact width here; use OVERLAY_MAX_WIDTH minus + // padding/borders as a reasonable upper bound for wrap math. The host + // gets a slightly generous answer when terminals are narrow, which is + // fine since options/typing pane already have minimum heights. + let content_width = OVERLAY_MAX_WIDTH + .saturating_sub(2 + CONTENT_PAD_X * 2) // 2 borders + L/R padding + .max(1) as usize; + + let question_h = wrapped_height(&self.question, content_width).clamp(1, 4); + let context_h = self + .context + .as_deref() + .map(|s| wrapped_height(s, content_width).min(4)) + .unwrap_or(0); + // Options list: each option uses 1 row + optional description + optional + // recommendation reason + 1 blank spacer. Plus the synthetic "Other" + // row. Plus an optional footer hint with leading blank. + let mut options_h: usize = 0; + for opt in &self.options { + options_h += 1; + if opt.description.is_some() { + options_h += 1; + } + if opt.recommended && opt.recommendation_reason.is_some() { + options_h += 1; + } + options_h += 1; // visual spacer + } + options_h += 1; // Other row + if self.reply_instructions.is_some() { + options_h += 2; // blank + hint + } + let options_h = options_h.max(3) as u16; + let typing_h: u16 = if matches!(self.mode, Mode::Typing) { + 5 + } else { + 0 + }; + + // 2 border rows + 1 top inset pad + 1 divider + 1 blank above options. + let mut total: u16 = 2 + 1 + question_h + 1 + 1 + options_h + typing_h; + if context_h > 0 { + total = total.saturating_add(context_h + 1); // blank + context + } + total + } + + fn render_into(&self, frame: &mut Frame, area: Rect, clear_under: bool) { + if clear_under { + // Clear underlying widgets so the modal is fully opaque (only when + // the modal is drawn as a floating overlay). + frame.render_widget(Clear, area); + } + + let title = Line::from(Span::styled( + format!(" {} ", self.title), + Style::default().fg(Color::White).bold(), + )); + let footer = self.footer_line(); + let outer = Block::default() + .title(title) + .title_bottom(footer) + .borders(Borders::ALL) + .border_style(Style::default().fg(PANEL_BORDER)) + .style(Style::default().bg(PANEL_BG)); + frame.render_widget(&outer, area); + let outer_inner = outer.inner(area); + + // Inset content from the border so text doesn't hug the edges. + let inner = Rect { + x: outer_inner.x + CONTENT_PAD_X, + y: outer_inner.y + 1, + width: outer_inner.width.saturating_sub(CONTENT_PAD_X * 2), + height: outer_inner.height.saturating_sub(1), + }; + + let content_width = inner.width.max(1) as usize; + let question_h = wrapped_height(&self.question, content_width).clamp(1, 4); + let context_h = self + .context + .as_deref() + .map(|s| wrapped_height(s, content_width).min(4)) + .unwrap_or(0); + let typing_h = if matches!(self.mode, Mode::Typing) { + 5 + } else { + 0 + }; + + // Vertical layout: + // question + // blank (only when context present) + // context (only when context present) + // divider + // blank + // options (fills) + // typing pane (only when active) + let mut constraints: Vec = Vec::with_capacity(7); + constraints.push(Constraint::Length(question_h)); + if context_h > 0 { + constraints.push(Constraint::Length(1)); // blank + constraints.push(Constraint::Length(context_h)); + } + constraints.push(Constraint::Length(1)); // divider + constraints.push(Constraint::Length(1)); // blank above options + constraints.push(Constraint::Min(3)); // options list + if typing_h > 0 { + constraints.push(Constraint::Length(typing_h)); + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + let mut slot = 0usize; + + // Question. + let question_para = Paragraph::new(self.question.clone()) + .style(Style::default().fg(Color::White).bold()) + .wrap(Wrap { trim: false }); + frame.render_widget(question_para, chunks[slot]); + slot += 1; + + if context_h > 0 { + slot += 1; // skip blank + let context_para = Paragraph::new(self.context.as_deref().unwrap_or("").to_string()) + .style(Style::default().fg(MUTED)) + .wrap(Wrap { trim: false }); + frame.render_widget(context_para, chunks[slot]); + slot += 1; + } + + // Divider that respects the content padding. + let divider_line = "─".repeat(inner.width as usize); + let divider = Paragraph::new(divider_line).style(Style::default().fg(SECTION_BORDER)); + frame.render_widget(divider, chunks[slot]); + slot += 1; + slot += 1; // blank above options + + let options_area = chunks[slot]; + slot += 1; + self.render_options(frame, options_area); + + if typing_h > 0 { + self.render_typing(frame, chunks[slot]); + } + } + + fn render_options(&self, frame: &mut Frame, area: Rect) { + let content_width = area.width as usize; + let mut lines: Vec> = Vec::with_capacity(self.rows() * 3); + + for (idx, opt) in self.options.iter().enumerate() { + lines.push(self.render_option_row(idx, opt, content_width)); + if let Some(desc) = opt.description.as_deref() { + lines.push(padded_secondary_line(desc, content_width, MUTED, false)); + } + if opt.recommended { + if let Some(reason) = opt.recommendation_reason.as_deref() { + lines.push(padded_secondary_line( + &format!("recommended: {}", reason), + content_width, + MUTED_DARK, + true, + )); + } + } + // Visual breathing room between options. + lines.push(Line::from("")); + } + lines.push(self.render_other_row(content_width)); + + if let Some(hint) = self.reply_instructions.as_deref() { + lines.push(Line::from("")); + lines.push(padded_secondary_line( + &format!("hint: {}", hint), + content_width, + MUTED_DARK, + true, + )); + } + + let para = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(para, area); + } + + fn render_option_row( + &self, + idx: usize, + opt: &AskUserOption, + content_width: usize, + ) -> Line<'static> { + let selected = self.cursor == idx; + let picked = self.allow_multiple && self.picked.get(idx).copied().unwrap_or(false); + + let arrow = if selected { "▌ " } else { " " }; + let check = if self.allow_multiple { + if picked { "[x] " } else { "[ ] " } + } else { + "" + }; + let recommended_tag = if opt.recommended { " ★" } else { "" }; + + let row_bg = if selected { + if opt.recommended { + SELECTED_BG_RECOMMENDED + } else { + SELECTED_BG + } + } else { + PANEL_BG + }; + + let row_fg = if opt.recommended { + RECOMMENDED_FG + } else { + OPTION_FG + }; + + let mut spans = vec![ + Span::styled(arrow.to_string(), Style::default().fg(row_fg).bg(row_bg)), + Span::styled(check.to_string(), Style::default().fg(row_fg).bg(row_bg)), + Span::styled( + format!("[{}] ", opt.id), + Style::default().fg(row_fg).bg(row_bg).bold(), + ), + Span::styled(opt.label.clone(), Style::default().fg(row_fg).bg(row_bg)), + ]; + if !recommended_tag.is_empty() { + spans.push(Span::styled( + recommended_tag.to_string(), + Style::default().fg(RECOMMENDED_FG).bg(row_bg), + )); + } + + // Pad to full content width so the background highlight extends to the + // right edge of the modal body. + let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + if used < content_width { + spans.push(Span::styled( + " ".repeat(content_width - used), + Style::default().bg(row_bg), + )); + } + + Line::from(spans) + } + + fn render_other_row(&self, content_width: usize) -> Line<'static> { + let selected = self.cursor == self.other_row(); + let arrow = if selected { "▌ " } else { " " }; + let check_pad = if self.allow_multiple { " " } else { "" }; + let bg = if selected { SELECTED_BG } else { PANEL_BG }; + + let mut spans = vec![ + Span::styled( + arrow.to_string(), + Style::default().fg(CUSTOM_HINT_FG).bg(bg), + ), + Span::styled( + check_pad.to_string(), + Style::default().fg(CUSTOM_HINT_FG).bg(bg), + ), + Span::styled( + "Other".to_string(), + Style::default().fg(CUSTOM_HINT_FG).bg(bg).bold(), + ), + Span::styled( + " type a custom answer".to_string(), + Style::default().fg(CUSTOM_HINT_FG).bg(bg).italic(), + ), + ]; + let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + if used < content_width { + spans.push(Span::styled( + " ".repeat(content_width - used), + Style::default().bg(bg), + )); + } + Line::from(spans) + } + + fn render_typing(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(Span::styled( + " Custom answer ", + Style::default().fg(CUSTOM_HINT_FG).bold(), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(CUSTOM_HINT_FG)); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Display typed text plus a blinking-style caret. + let mut text = self.typed.clone(); + text.push('▏'); + let para = Paragraph::new(Line::from(Span::styled( + text, + Style::default().fg(Color::White), + ))) + .wrap(Wrap { trim: false }); + frame.render_widget(para, inner); + } + + fn footer_line(&self) -> Line<'static> { + if matches!(self.mode, Mode::Typing) { + Line::from(vec![ + hotkey(" Enter "), + Span::styled(" submit ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" back to options ", Style::default().fg(MUTED_DARK)), + ]) + } else if self.allow_multiple { + Line::from(vec![ + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" Space "), + Span::styled(" toggle ", Style::default().fg(MUTED_DARK)), + hotkey(" Enter "), + Span::styled(" submit ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" cancel ", Style::default().fg(MUTED_DARK)), + ]) + } else { + Line::from(vec![ + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" Enter "), + Span::styled(" pick ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" cancel ", Style::default().fg(MUTED_DARK)), + ]) + } + } +} + +fn hotkey(label: &str) -> Span<'static> { + Span::styled( + label.to_string(), + Style::default() + .bg(Color::Rgb(60, 70, 95)) + .fg(Color::White) + .bold(), + ) +} + +/// Number of visual rows `text` will occupy when wrapped to `width` columns. +/// Handles ASCII naively (good enough for the question + context strings the +/// agent is expected to emit; we cap the result in the caller). +fn wrapped_height(text: &str, width: usize) -> u16 { + if width == 0 { + return 1; + } + let len = text.chars().count(); + if len == 0 { + return 1; + } + let rows = len.div_ceil(width); + rows.max(1) as u16 +} + +/// Build a left-indented secondary line with a uniform style and pad it to +/// `content_width` so the modal feels grid-aligned (no ragged right edge). +fn padded_secondary_line( + text: &str, + content_width: usize, + fg: Color, + italic: bool, +) -> Line<'static> { + let body = format!(" {}", text); + let style = if italic { + Style::default().fg(fg).italic() + } else { + Style::default().fg(fg) + }; + let body_len = body.chars().count(); + let pad = content_width.saturating_sub(body_len); + Line::from(vec![ + Span::styled(body, style), + Span::styled(" ".repeat(pad), Style::default()), + ]) +} + +fn centered_rect(area: Rect) -> Rect { + // Width: percent of screen, clamped to [MIN, MAX], and never wider than the + // available area. + let width_pct = (area.width as u32 * OVERLAY_PERCENT_X as u32 / 100) as u16; + let width = width_pct + .clamp(OVERLAY_MIN_WIDTH, OVERLAY_MAX_WIDTH) + .min(area.width.saturating_sub(2).max(OVERLAY_MIN_WIDTH)); + // Height: grows with the screen but never less than the minimum and never + // more than two-thirds of the screen so the chat stays visible behind it. + let two_thirds = (area.height as u32 * 2 / 3) as u16; + let height = OVERLAY_MIN_HEIGHT + .max(two_thirds) + .min(area.height.saturating_sub(2)); + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width, + height, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ask_user::AskUserOption; + + fn sample_question() -> AskUserQuestion { + AskUserQuestion { + request_id: "req".into(), + session_id: "ses".into(), + question: "Pick".into(), + context: Some("Why".into()), + options: vec![ + AskUserOption { + id: "A".into(), + label: "Alpha".into(), + description: None, + value: None, + recommended: false, + recommendation_reason: None, + }, + AskUserOption { + id: "B".into(), + label: "Beta".into(), + description: Some("preferred".into()), + value: Some("b-value".into()), + recommended: true, + recommendation_reason: Some("safer".into()), + }, + ], + allow_multiple: false, + reply_instructions: None, + title: None, + } + } + + #[test] + fn cursor_starts_on_recommended() { + let m = AskUserModal::from_question(sample_question()); + assert_eq!(m.cursor, 1); + } + + #[test] + fn arrow_keys_wrap() { + let mut m = AskUserModal::from_question(sample_question()); + // 2 options + 1 other row = 3 rows. Starting at cursor=1 (recommended). + m.move_cursor(1); + assert_eq!(m.cursor, 2); // Other + m.move_cursor(1); + assert_eq!(m.cursor, 0); // wraps to first option + m.move_cursor(-1); + assert_eq!(m.cursor, 2); // wraps backwards to Other + } + + #[test] + fn enter_on_option_submits_options_answer() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = 1; + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + match answer.kind { + AskUserAnswerKind::Options { + ids, + labels, + values, + } => { + assert_eq!(ids, vec!["B"]); + assert_eq!(labels, vec!["Beta"]); + assert_eq!(values, vec![Some("b-value".into())]); + } + other => panic!("unexpected kind: {other:?}"), + } + } + + #[test] + fn esc_cancels() { + let mut m = AskUserModal::from_question(sample_question()); + let out = m.handle_key(KeyCode::Esc, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + assert!(matches!(answer.kind, AskUserAnswerKind::Canceled)); + } + + #[test] + fn other_row_switches_to_typing_then_submits_custom() { + let mut m = AskUserModal::from_question(sample_question()); + // Move to Other row. + m.cursor = m.other_row(); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert!(matches!(out, AskUserModalOutcome::Continue)); + assert!(matches!(m.mode, Mode::Typing)); + + // Type "hi" then Enter. + m.handle_key(KeyCode::Char('h'), KeyModifiers::NONE); + m.handle_key(KeyCode::Char('i'), KeyModifiers::NONE); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + match answer.kind { + AskUserAnswerKind::Custom { text } => assert_eq!(text, "hi"), + other => panic!("unexpected kind: {other:?}"), + } + } + + #[test] + fn empty_custom_does_not_submit() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = m.other_row(); + m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert!(matches!(out, AskUserModalOutcome::Continue)); + } + + #[test] + fn typing_esc_returns_to_choosing() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = m.other_row(); + m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + m.handle_key(KeyCode::Char('x'), KeyModifiers::NONE); + let out = m.handle_key(KeyCode::Esc, KeyModifiers::NONE); + assert!(matches!(out, AskUserModalOutcome::Continue)); + assert!(matches!(m.mode, Mode::Choosing)); + assert_eq!(m.typed, "x"); // preserves text in case user comes back + } + + #[test] + fn quick_select_by_id_letter() { + let mut m = AskUserModal::from_question(sample_question()); + m.cursor = 0; + m.handle_key(KeyCode::Char('b'), KeyModifiers::NONE); + assert_eq!(m.cursor, 1); + } + + #[test] + fn multi_select_space_toggles() { + let mut q = sample_question(); + q.allow_multiple = true; + let mut m = AskUserModal::from_question(q); + m.cursor = 0; + m.handle_key(KeyCode::Char(' '), KeyModifiers::NONE); + assert!(m.picked[0]); + m.cursor = 1; + m.handle_key(KeyCode::Char(' '), KeyModifiers::NONE); + assert!(m.picked[1]); + let out = m.handle_key(KeyCode::Enter, KeyModifiers::NONE); + let AskUserModalOutcome::Done(answer) = out else { + panic!("expected Done"); + }; + match answer.kind { + AskUserAnswerKind::Options { ids, .. } => { + assert_eq!(ids, vec!["A", "B"]); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/src/tui/backend.rs b/src/tui/backend.rs index b7e942432..808e9f99d 100644 --- a/src/tui/backend.rs +++ b/src/tui/backend.rs @@ -698,6 +698,19 @@ impl RemoteConnection { self.send_request(request).await } + /// Submit the user's answer to an outstanding `askUserQuestion` modal. + /// Fire-and-forget: the server applies it to its pending registry which + /// in turn wakes the tool task. We use the detached path so the modal + /// dispatch from the synchronous key handler does not need an .await. + pub fn submit_ask_user_answer(&mut self, answer: jcode_protocol::AskUserAnswerPayload) { + let id = self.next_request_id; + self.next_request_id += 1; + self.send_request_detached( + Request::SubmitAskUserAnswer { id, answer }, + "submit_ask_user_answer", + ); + } + /// Split the current session — ask server to clone conversation into a new session pub async fn split(&mut self) -> Result { let id = self.next_request_id; @@ -821,12 +834,22 @@ impl RemoteConnection { )); continue; } + let trimmed = self.line_buffer.trim_start(); + if !trimmed.starts_with('{') && !trimmed.starts_with('[') { + let preview: String = self.line_buffer.chars().take(240).collect(); + crate::logging::warn(&format!( + "RemoteConnection::next_event: skipping non-json stray line preview={:?} (session_id={:?}, client_instance_id={:?})", + preview, self.session_id, self.client_instance_id + )); + continue; + } match serde_json::from_str(&self.line_buffer) { Ok(event) => return RemoteRead::Event(event), Err(error) => { + let preview: String = self.line_buffer.chars().take(240).collect(); crate::logging::warn(&format!( - "RemoteConnection::next_event: protocol error={} line={:?} (session_id={:?}, client_instance_id={:?})", - error, self.line_buffer, self.session_id, self.client_instance_id + "RemoteConnection::next_event: protocol error={} line_preview={:?} (session_id={:?}, client_instance_id={:?})", + error, preview, self.session_id, self.client_instance_id )); return RemoteRead::Disconnected(RemoteDisconnectReason::Protocol( error.to_string(), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5088cab81..b49f135f0 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,6 @@ pub mod account_picker; mod app; +pub mod ask_user_modal; pub mod backend; pub(crate) mod color_support; mod core; @@ -286,6 +287,8 @@ pub trait TuiState { fn account_picker_overlay(&self) -> Option<&std::cell::RefCell>; /// Usage overlay for /usage command fn usage_overlay(&self) -> Option<&std::cell::RefCell>; + /// `askUserQuestion` modal overlay (None = not visible). + fn ask_user_overlay(&self) -> Option<&std::cell::RefCell>; /// Working directory for this session fn working_dir(&self) -> Option; /// Monotonic clock for viewport animations @@ -1069,7 +1072,6 @@ pub(crate) fn redraw_interval_with_policy( if state.is_processing() || !state.streaming_text().is_empty() - || state.status_notice().is_some() || state.has_pending_mouse_scroll_animation() || state.has_notification() || state.rate_limit_remaining().is_some() @@ -1080,6 +1082,14 @@ pub(crate) fn redraw_interval_with_policy( }; } + // Status notices are static text with a short TTL. They need periodic + // redraws so they disappear, but they should not drive the high-frequency + // render loop over long transcripts while the user is just typing a slash + // command. + if state.status_notice().is_some() { + return REDRAW_PASSIVE_LIVENESS; + } + if state.remote_startup_phase_active() { return REDRAW_REMOTE_STARTUP; } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 28dcc10c4..69c0c78b6 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1831,9 +1831,21 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { input_ui::wrapped_input_line_count(app, chat_area.width, next_prompt).min(10) as u16; // Add 1 line for command suggestions, shell mode hints, or the Ctrl+Enter hint. let hint_line_height = input_ui::input_hint_line_height(app); - let inline_block_height: u16 = inline_ui_height(app); - let inline_ui_gap_height: u16 = if inline_block_height > 0 { 1 } else { 0 }; - let input_height = base_input_height + hint_line_height; + let mut inline_block_height: u16 = inline_ui_height(app); + let mut inline_ui_gap_height: u16 = if inline_block_height > 0 { 1 } else { 0 }; + let mut input_height = base_input_height + hint_line_height; + + // When the askUserQuestion modal is visible, take over the input chunk + // entirely (Claude-Code style): hide the regular input, inline UI, etc. + let ask_user_height: u16 = app + .ask_user_overlay() + .map(|c| c.borrow().desired_height()) + .unwrap_or(0); + if ask_user_height > 0 { + input_height = ask_user_height; + inline_block_height = 0; + inline_ui_gap_height = 0; + } if let Some(ref mut capture) = debug_capture { capture.render_order.push("prepare_messages".to_string()); @@ -1847,8 +1859,14 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { prepare::prepare_messages(app, wide_prepare_width, chat_area.height) }); let show_donut = super::idle_donut_active(app); - let donut_height: u16 = if show_donut { 14 } else { 0 }; - let notification_height: u16 = if app.has_notification() { 1 } else { 0 }; + let mut donut_height: u16 = if show_donut { 14 } else { 0 }; + let mut notification_height: u16 = if app.has_notification() { 1 } else { 0 }; + if ask_user_height > 0 { + // The modal absorbs the input slot; suppress sibling chunks so the + // chat above stays visible and stable. + notification_height = 0; + donut_height = 0; + } let fixed_height = 1 + queued_height + notification_height @@ -2118,16 +2136,24 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { draw_inline_ui(frame, app, chunks[4]); } - input_ui::draw_input( - frame, - app, - chunks[6], - user_count + pending_count + 1, - &mut debug_capture, - ); + if ask_user_height > 0 { + // The modal owns the input chunk: render it there and skip the + // regular input box / idle animation entirely. + if let Some(modal_cell) = app.ask_user_overlay() { + modal_cell.borrow().render_inline(frame, chunks[6]); + } + } else { + input_ui::draw_input( + frame, + app, + chunks[6], + user_count + pending_count + 1, + &mut debug_capture, + ); - if donut_height > 0 { - animations::draw_idle_animation(frame, app, chunks[7]); + if donut_height > 0 { + animations::draw_idle_animation(frame, app, chunks[7]); + } } // Draw info widget overlays (skip during idle animation - they look out of place) @@ -2220,6 +2246,10 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { visual_debug::record_frame(capture.build()); } + // Note: The askUserQuestion modal is rendered inline above as part of the + // input chunk (chunks[6]) so it cleanly replaces the input box and + // communicates that the agent is blocked on the user. + finalize_frame_metrics( app, total_start, diff --git a/src/tui/ui_tests/mod.rs b/src/tui/ui_tests/mod.rs index 1505e75a2..4c8aea10f 100644 --- a/src/tui/ui_tests/mod.rs +++ b/src/tui/ui_tests/mod.rs @@ -403,6 +403,11 @@ impl crate::tui::TuiState for TestState { ) -> Option<&std::cell::RefCell> { None } + fn ask_user_overlay( + &self, + ) -> Option<&std::cell::RefCell> { + None + } fn working_dir(&self) -> Option { None } diff --git a/src/update.rs b/src/update.rs index 2fbd9728b..8da746cea 100644 --- a/src/update.rs +++ b/src/update.rs @@ -204,6 +204,7 @@ pub fn fetch_latest_release_blocking() -> Result { let client = reqwest::blocking::Client::builder() .timeout(UPDATE_CHECK_TIMEOUT) .user_agent("jcode-updater") + .no_proxy() .build()?; let response = client @@ -229,6 +230,7 @@ fn latest_main_sha_blocking() -> Result { let client = reqwest::blocking::Client::builder() .timeout(UPDATE_CHECK_TIMEOUT) .user_agent("jcode-updater") + .no_proxy() .build()?; let response = client @@ -764,6 +766,7 @@ pub fn download_and_install_blocking_with_progress( let client = reqwest::blocking::Client::builder() .timeout(DOWNLOAD_TIMEOUT) .user_agent("jcode-updater") + .no_proxy() .build()?; let mut response = client