diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2d4126b..e292049 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@claude-prism/desktop", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/public/favicon.ico b/apps/desktop/public/favicon.ico index 1e5c3eb..fa2a6d7 100644 Binary files a/apps/desktop/public/favicon.ico and b/apps/desktop/public/favicon.ico differ diff --git a/apps/desktop/public/icon-192.png b/apps/desktop/public/icon-192.png index 8891ff8..c0cb9ba 100644 Binary files a/apps/desktop/public/icon-192.png and b/apps/desktop/public/icon-192.png differ diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 36accd5..464958a 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -502,7 +502,7 @@ dependencies = [ [[package]] name = "claude-prism-desktop" -version = "1.1.7" +version = "1.3.0" dependencies = [ "base64 0.22.1", "chrono", @@ -532,7 +532,7 @@ dependencies = [ "tokio", "uuid", "which 7.0.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "winreg 0.55.0", ] @@ -2172,9 +2172,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.5.10", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4054,7 +4056,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tower-service", @@ -4074,6 +4076,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http 1.4.2", "http-body 1.0.1", "http-body-util", @@ -4093,12 +4096,14 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -4138,7 +4143,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -4965,7 +4970,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -4978,6 +4994,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -6520,6 +6546,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -6860,8 +6899,8 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -6919,6 +6958,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6928,6 +6978,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6937,6 +6996,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8b659ef..3510b84 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-prism-desktop" -version = "1.2.0" +version = "1.3.0" description = "AI-powered LaTeX writing workspace" edition = "2021" @@ -23,7 +23,7 @@ serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["full"] } dirs = "5" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] } hmac = "0.12" sha1 = "0.10" base64 = "0.22" @@ -54,7 +54,7 @@ objc2-foundation = { version = "0.3", features = ["NSData"] } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.55" -windows-sys = { version = "0.52", features = ["Win32_System_Environment"] } +windows-sys = { version = "0.61", features = ["Win32_System_Environment"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index 782b802..57d6b74 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index 6b02b9c..4065ae0 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/256x256.png b/apps/desktop/src-tauri/icons/256x256.png index 6b02b9c..69342c8 100644 Binary files a/apps/desktop/src-tauri/icons/256x256.png and b/apps/desktop/src-tauri/icons/256x256.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index 90a8975..3647d0b 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..631b87b Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..ff75477 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..9fe0c4e Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..eae93d6 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..d560aa3 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..70bc982 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..b5e0fe8 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..84a88de Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..b9353bf Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..6ba213b Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..33dafc8 Binary files /dev/null and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..d7a8c60 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..298fdf9 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..af4f218 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..a8338b2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..246a357 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9896728 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..269df92 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..29f1441 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dde292a Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..49031d6 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..45333d7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..23597e0 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f508848 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..181dd88 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..26ab8fc Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index 5c2bac8..d23adba 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index 4928da2..fa2a6d7 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index 110feda..f116aa7 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..cda8276 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..e766a97 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..e766a97 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..805f0f1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..ad88f12 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..9357ee2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..9357ee2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..205c555 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..e766a97 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..0d07ca3 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..0d07ca3 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..dac4879 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..db277e1 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..dac4879 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..8fc32bc Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..2ff3e6e Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..d6b0816 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..280b87a Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/desktop/src-tauri/src/anthropic_proxy.rs b/apps/desktop/src-tauri/src/anthropic_proxy.rs new file mode 100644 index 0000000..7873b85 --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy.rs @@ -0,0 +1,614 @@ +mod messages; +mod providers; +mod stream; +mod tools; +mod transformers; + +use self::messages::{anthropic_to_openai_request, openai_to_anthropic_message}; +use self::providers::apply_provider_request_transforms; +use self::stream::{sse_response, stream_openai_sse_to_anthropic}; +use self::transformers::ProxyTransformerChain; +use serde_json::{json, Value}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +#[derive(Clone, Debug)] +pub(crate) struct OpenAiProxyCredential { + pub(crate) api_key: String, + pub(crate) base_url: String, + pub(crate) model: String, + pub(crate) transformers: Vec, + pub(crate) model_transformers: Vec, +} + +pub(crate) async fn start_openai_anthropic_proxy( + credential: OpenAiProxyCredential, +) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|err| format!("Failed to start local provider proxy: {}", err))?; + let addr = listener + .local_addr() + .map_err(|err| format!("Failed to read local provider proxy address: {}", err))?; + let credential = Arc::new(credential); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let credential = Arc::clone(&credential); + tokio::spawn(async move { + if let Err(err) = handle_connection(stream, credential).await { + eprintln!("[anthropic-proxy] request failed: {}", err); + } + }); + } + }); + + Ok(format!("http://{}", addr)) +} + +async fn handle_connection( + mut stream: TcpStream, + credential: Arc, +) -> Result<(), String> { + let request = read_http_request(&mut stream).await?; + let path = request_path_without_query(&request.path); + if request.method == "POST" && is_messages_path(path) { + match handle_messages_to_stream(&request, &credential, &mut stream).await { + Ok(()) => { + let _ = stream.shutdown().await; + return Ok(()); + } + Err(err) => { + let response = json_response( + 502, + &json!({ + "type": "error", + "error": { + "type": "api_error", + "message": err, + }, + }), + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|err| format!("Failed to write proxy error response: {}", err))?; + let _ = stream.shutdown().await; + return Ok(()); + } + } + } + + let response = route_request(&request).await; + stream + .write_all(response.as_bytes()) + .await + .map_err(|err| format!("Failed to write proxy response: {}", err))?; + let _ = stream.shutdown().await; + Ok(()) +} + +struct HttpRequest { + method: String, + path: String, + body: Vec, +} + +async fn read_http_request(stream: &mut TcpStream) -> Result { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 8192]; + let header_end = loop { + let n = stream + .read(&mut temp) + .await + .map_err(|err| format!("Failed to read proxy request: {}", err))?; + if n == 0 { + return Err("Connection closed before HTTP headers were received".to_string()); + } + buffer.extend_from_slice(&temp[..n]); + if let Some(index) = find_header_end(&buffer) { + break index; + } + if buffer.len() > 1024 * 1024 { + return Err("Proxy request headers are too large".to_string()); + } + }; + + let header_text = String::from_utf8_lossy(&buffer[..header_end]); + let mut lines = header_text.lines(); + let request_line = lines + .next() + .ok_or_else(|| "Proxy request is missing request line".to_string())?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| "Proxy request is missing method".to_string())? + .to_string(); + let path = request_parts + .next() + .ok_or_else(|| "Proxy request is missing path".to_string())? + .to_string(); + + let content_length = lines + .filter_map(|line| line.split_once(':')) + .find(|(key, _)| key.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + + let body_start = header_end + 4; + let mut body = buffer.get(body_start..).unwrap_or_default().to_vec(); + while body.len() < content_length { + let n = stream + .read(&mut temp) + .await + .map_err(|err| format!("Failed to read proxy request body: {}", err))?; + if n == 0 { + break; + } + body.extend_from_slice(&temp[..n]); + } + body.truncate(content_length); + + Ok(HttpRequest { method, path, body }) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +async fn route_request(request: &HttpRequest) -> String { + let path = request_path_without_query(&request.path); + + if request.method == "GET" && path == "/" { + return json_response( + 200, + &json!({ "ok": true, "service": "claude-prism-anthropic-proxy" }), + ); + } + + if request.method == "POST" && is_count_tokens_path(path) { + return handle_count_tokens(request); + } + + json_response( + 400, + &json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": format!("Unsupported Anthropic proxy endpoint: {} {}", request.method, request.path), + }, + }), + ) +} + +fn request_path_without_query(path: &str) -> &str { + path.split_once('?').map(|(path, _)| path).unwrap_or(path) +} + +fn is_count_tokens_path(path: &str) -> bool { + path.ends_with("/count_tokens") +} + +fn is_messages_path(path: &str) -> bool { + path.ends_with("/messages") +} + +fn handle_count_tokens(request: &HttpRequest) -> String { + let body = serde_json::from_slice::(&request.body).unwrap_or(Value::Null); + let approx_chars = body.to_string().chars().count(); + json_response( + 200, + &json!({ + "input_tokens": (approx_chars / 4).max(1), + }), + ) +} + +async fn handle_messages_to_stream( + request: &HttpRequest, + credential: &OpenAiProxyCredential, + stream: &mut TcpStream, +) -> Result<(), String> { + let anthropic_request: Value = serde_json::from_slice(&request.body) + .map_err(|err| format!("Claude Code sent invalid Anthropic JSON: {}", err))?; + let wants_stream = anthropic_request + .get("stream") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let transformers = ProxyTransformerChain::for_credential(credential, wants_stream); + let mut openai_request = + anthropic_to_openai_request(&anthropic_request, credential, &transformers)?; + openai_request["stream"] = Value::Bool(wants_stream); + apply_provider_request_transforms( + &mut openai_request, + &anthropic_request, + credential, + wants_stream, + &transformers, + ); + if request_contains_openai_image_parts(&openai_request) + && provider_rejects_openai_image_parts(credential) + { + return Err(format!( + "{} does not accept OpenAI-style image_url message parts. Switch to Claude Code or a vision-capable OpenAI-compatible endpoint for image questions.", + credential.model + )); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|err| format!("Failed to create provider client: {}", err))?; + let request = client + .post(openai_chat_completions_url(&credential.base_url)) + .header("Content-Type", "application/json") + .body(openai_request.to_string()); + let response = with_optional_bearer_auth(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider request failed: {}", err))?; + + let status = response.status(); + if !status.is_success() { + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider error response: {}", err))?; + return Err(format!( + "Provider returned HTTP {}: {}", + status, + compact_error_text(&response_text) + )); + } + + if wants_stream { + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + if content_type.contains("stream") { + stream_openai_sse_to_anthropic(stream, response, &anthropic_request, credential).await + } else { + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider response: {}", err))?; + let openai_response: Value = serde_json::from_str(&response_text) + .map_err(|err| format!("Provider returned invalid JSON: {}", err))?; + let anthropic_response = + openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?; + stream + .write_all(sse_response(&anthropic_response).as_bytes()) + .await + .map_err(|err| format!("Failed to write proxy SSE response: {}", err)) + } + } else { + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider response: {}", err))?; + let openai_response: Value = serde_json::from_str(&response_text) + .map_err(|err| format!("Provider returned invalid JSON: {}", err))?; + let anthropic_response = + openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?; + stream + .write_all(json_response(200, &anthropic_response).as_bytes()) + .await + .map_err(|err| format!("Failed to write proxy JSON response: {}", err)) + } +} + +fn openai_chat_completions_url(base_url: &str) -> String { + let clean = base_url.trim_end_matches('/'); + if clean.ends_with("/chat/completions") { + clean.to_string() + } else if openai_compatible_base_url_has_chat_root(clean) { + format!("{}/chat/completions", clean) + } else { + format!("{}/v1/chat/completions", clean) + } +} + +fn with_optional_bearer_auth( + request: reqwest::RequestBuilder, + api_key: &str, +) -> reqwest::RequestBuilder { + if api_key.trim().is_empty() { + request + } else { + request.bearer_auth(api_key) + } +} + +fn request_contains_openai_image_parts(value: &Value) -> bool { + match value { + Value::Array(values) => values.iter().any(request_contains_openai_image_parts), + Value::Object(object) => { + object.get("type").and_then(Value::as_str) == Some("image_url") + || object.values().any(request_contains_openai_image_parts) + } + _ => false, + } +} + +fn provider_rejects_openai_image_parts(credential: &OpenAiProxyCredential) -> bool { + let base_url = credential.base_url.to_ascii_lowercase(); + base_url == "https://api.deepseek.com" || base_url.starts_with("https://api.deepseek.com/") +} + +fn openai_compatible_base_url_has_chat_root(base_url: &str) -> bool { + let lower = base_url.to_ascii_lowercase(); + if lower == "https://api.deepseek.com" { + return true; + } + + let path = lower + .split_once("://") + .and_then(|(_, rest)| rest.split_once('/').map(|(_, path)| path)) + .unwrap_or("") + .trim_matches('/'); + if path.is_empty() { + return false; + } + + let segments = path.split('/').collect::>(); + let last = segments.last().copied().unwrap_or_default(); + matches!(last, "v1" | "v2" | "v3" | "v4" | "beta") + || path.ends_with("/openai") + || path.ends_with("compatible-mode/v1") +} + +fn json_response(status: u16, value: &Value) -> String { + http_response( + status, + "application/json; charset=utf-8", + &value.to_string(), + ) +} + +fn http_response(status: u16, content_type: &str, body: &str) -> String { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 502 => "Bad Gateway", + _ => "Internal Server Error", + }; + format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + reason, + content_type, + body.as_bytes().len(), + body + ) +} + +fn compact_error_text(text: &str) -> String { + let compact = text.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= 1000 { + compact + } else { + format!("{}...", compact.chars().take(1000).collect::()) + } +} + +#[allow(dead_code)] +fn _assert_local_addr(_: SocketAddr) {} + +#[cfg(test)] +mod tests { + use super::transformers::ProxyTransformerChain; + use super::*; + + #[test] + fn recognizes_anthropic_messages_paths_with_query_strings() { + let path = request_path_without_query("/v1/messages?beta=tools"); + assert_eq!(path, "/v1/messages"); + assert!(is_messages_path(path)); + assert!(is_count_tokens_path("/v1/messages/count_tokens")); + } + + #[test] + fn detects_openai_image_parts_for_provider_guard() { + assert!(request_contains_openai_image_parts(&json!({ + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": "what is this?" }, + { "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } } + ] + }] + }))); + assert!(!request_contains_openai_image_parts(&json!({ + "messages": [{ "role": "user", "content": "text only" }] + }))); + } + + #[test] + fn converts_tool_use_and_tool_result_messages() { + let credential = OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: "https://api.example.com/v1".to_string(), + model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + }; + let request = json!({ + "system": "system prompt", + "messages": [ + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_1", + "name": "Read", + "input": { "file_path": "main.tex" } + }] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "file text" + }] + } + ], + "tools": [{ + "name": "Read", + "description": "Read a file", + "input_schema": { "type": "object" } + }] + }); + + let converted = anthropic_to_openai_request( + &request, + &credential, + &ProxyTransformerChain::from_names(&[]), + ) + .unwrap(); + assert_eq!(converted["model"], "qwen-test"); + assert_eq!(converted["messages"][0]["role"], "system"); + assert_eq!( + converted["messages"][1]["tool_calls"][0]["function"]["name"], + "Read" + ); + assert_eq!(converted["messages"][2]["role"], "tool"); + assert_eq!(converted["tools"][0]["function"]["name"], "Read"); + } + + #[test] + fn keeps_tool_results_immediately_after_tool_calls() { + let credential = OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: "https://api.example.com/v1".to_string(), + model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + }; + let request = json!({ + "messages": [ + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_1", + "name": "Read", + "input": { "file_path": "main.tex" } + }] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Now explain it." + }, + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "file text" + } + ] + } + ] + }); + + let converted = anthropic_to_openai_request( + &request, + &credential, + &ProxyTransformerChain::from_names(&[]), + ) + .unwrap(); + assert_eq!(converted["messages"][0]["role"], "assistant"); + assert_eq!(converted["messages"][1]["role"], "tool"); + assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_1"); + assert_eq!(converted["messages"][2]["role"], "user"); + assert_eq!(converted["messages"][2]["content"], "Now explain it."); + } + + #[test] + fn synthesizes_missing_tool_results_before_user_messages() { + let credential = OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: "https://api.example.com/v1".to_string(), + model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + }; + let request = json!({ + "messages": [ + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_missing", + "name": "Read", + "input": { "file_path": "main.tex" } + }] + }, + { + "role": "user", + "content": "continue" + } + ] + }); + + let converted = anthropic_to_openai_request( + &request, + &credential, + &ProxyTransformerChain::from_names(&[]), + ) + .unwrap(); + assert_eq!(converted["messages"][0]["role"], "assistant"); + assert_eq!(converted["messages"][1]["role"], "tool"); + assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_missing"); + assert_eq!(converted["messages"][2]["role"], "user"); + assert_eq!(converted["messages"][2]["content"], "continue"); + } + + #[test] + fn converts_openai_tool_call_to_anthropic_message() { + let credential = OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: "https://api.example.com/v1".to_string(), + model: "deepseek-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + }; + let request = json!({ "model": "claude-sonnet-4" }); + let response = json!({ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "Grep", + "arguments": "{\"pattern\":\"FastVID\"}" + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 3 } + }); + + let converted = openai_to_anthropic_message(&request, &response, &credential).unwrap(); + assert_eq!(converted["stop_reason"], "tool_use"); + assert_eq!(converted["content"][0]["type"], "tool_use"); + assert_eq!(converted["content"][0]["input"]["pattern"], "FastVID"); + } +} diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs b/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs new file mode 100644 index 0000000..133eea5 --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy/messages.rs @@ -0,0 +1,899 @@ +use super::tools::{normalized_tool_call_id, repair_tool_arguments, repaired_tool_arguments_value}; +use super::transformers::ProxyTransformerChain; +use super::OpenAiProxyCredential; +use serde_json::{json, Value}; + +const EXIT_TOOL_NAME: &str = "ExitTool"; + +pub(super) fn anthropic_to_openai_request( + request: &Value, + credential: &OpenAiProxyCredential, + transformers: &ProxyTransformerChain, +) -> Result { + let mut messages = Vec::new(); + if let Some(system) = request.get("system").and_then(flatten_anthropic_content) { + if !system.trim().is_empty() { + messages.push(json!({ "role": "system", "content": system })); + } + } + + for message in request + .get("messages") + .and_then(|value| value.as_array()) + .ok_or_else(|| "Anthropic request is missing messages[]".to_string())? + { + append_openai_messages_for_anthropic_message(&mut messages, message); + } + let messages = normalize_openai_tool_message_pairs(messages); + + let mut body = json!({ + "model": credential.model, + "messages": messages, + "stream": false, + }); + copy_number_field(request, &mut body, "temperature"); + copy_number_field(request, &mut body, "top_p"); + copy_number_field(request, &mut body, "top_k"); + copy_number_field(request, &mut body, "max_tokens"); + if let Some(stop) = request.get("stop_sequences") { + body["stop"] = stop.clone(); + } + + if let Some(tools) = request.get("tools").and_then(|value| value.as_array()) { + let converted = tools + .iter() + .filter_map(anthropic_tool_to_openai_tool) + .collect::>(); + if !converted.is_empty() { + let tool_choice = if transformers.has_tooluse() { + Value::String("required".to_string()) + } else { + openai_tool_choice(request.get("tool_choice")) + }; + let mut converted = converted; + if tool_choice == Value::String("required".to_string()) { + append_exit_tool(&mut converted); + append_exit_tool_reminder(&mut body); + } + body["tools"] = Value::Array(converted); + body["tool_choice"] = tool_choice; + } + } + + Ok(body) +} + +pub(super) fn openai_to_anthropic_message( + anthropic_request: &Value, + openai_response: &Value, + credential: &OpenAiProxyCredential, +) -> Result { + let message = openai_response + .pointer("/choices/0/message") + .ok_or_else(|| "Provider response is missing choices[0].message".to_string())?; + let mut content = Vec::new(); + + if let Some(reasoning) = openai_message_thinking(message) { + content.push(json!({ "type": "thinking", "thinking": reasoning })); + } + + if let Some(text) = openai_message_text(message).filter(|value| !value.trim().is_empty()) { + content.push(json!({ "type": "text", "text": text })); + } + + if let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) { + for call in tool_calls { + let function = call.get("function").unwrap_or(&Value::Null); + let name = function + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let arguments = function + .get("arguments") + .and_then(|value| value.as_str()) + .unwrap_or("{}"); + if name == EXIT_TOOL_NAME { + if let Some(response) = exit_tool_response(arguments) { + content.push(json!({ "type": "text", "text": response })); + } + continue; + } + let input = repaired_tool_arguments_value(arguments); + let id = normalized_tool_call_id(call.get("id").and_then(|value| value.as_str())); + content.push(json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input, + })); + } + } + + if content.is_empty() { + content.push(json!({ "type": "text", "text": "" })); + } + + let finish_reason = openai_response + .pointer("/choices/0/finish_reason") + .and_then(|value| value.as_str()); + let stop_reason = if content + .iter() + .any(|block| block.get("type").and_then(|value| value.as_str()) == Some("tool_use")) + { + "tool_use" + } else { + match finish_reason { + Some("length") => "max_tokens", + Some("tool_calls") if !contains_only_exit_tool(message) => "tool_use", + _ => "end_turn", + } + }; + + let usage = openai_response.get("usage").unwrap_or(&Value::Null); + Ok(json!({ + "id": openai_response + .get("id") + .and_then(|value| value.as_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())), + "type": "message", + "role": "assistant", + "model": anthropic_request + .get("model") + .and_then(|value| value.as_str()) + .unwrap_or(&credential.model), + "content": content, + "stop_reason": stop_reason, + "stop_sequence": Value::Null, + "usage": { + "input_tokens": usage_token(usage, &["prompt_tokens", "input_tokens", "prompt_token_count"]), + "output_tokens": usage_token(usage, &["completion_tokens", "output_tokens", "completion_token_count"]), + }, + })) +} + +fn append_openai_messages_for_anthropic_message(messages: &mut Vec, message: &Value) { + let role = message + .get("role") + .and_then(|value| value.as_str()) + .unwrap_or("user"); + let content = message.get("content").unwrap_or(&Value::Null); + + if role == "assistant" { + let (text, tool_calls, thinking) = assistant_content_to_openai(content); + let mut openai_message = json!({ + "role": "assistant", + "content": if text.trim().is_empty() { Value::Null } else { Value::String(text) }, + }); + if !tool_calls.is_empty() { + openai_message["tool_calls"] = Value::Array(tool_calls); + } + if let Some(thinking) = thinking { + openai_message["thinking"] = thinking; + } + messages.push(openai_message); + return; + } + + if let Some(blocks) = content.as_array() { + let content_parts = user_content_blocks_to_openai_parts(blocks); + if !content_parts.is_empty() { + let content = if content_parts.len() == 1 + && content_parts[0] + .get("type") + .and_then(|value| value.as_str()) + == Some("text") + { + content_parts[0] + .get("text") + .cloned() + .unwrap_or_else(|| json!("")) + } else { + Value::Array(content_parts) + }; + messages.push(json!({ "role": role, "content": content })); + } + + for block in blocks { + if block.get("type").and_then(|value| value.as_str()) != Some("tool_result") { + continue; + } + let tool_call_id = block + .get("tool_use_id") + .and_then(|value| value.as_str()) + .unwrap_or("toolu_unknown"); + let (content, image_parts) = + tool_result_content_to_openai(block.get("content").unwrap_or(&Value::Null)); + let content = if content.trim().is_empty() && !image_parts.is_empty() { + "Tool returned image content.".to_string() + } else { + content + }; + messages.push(json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + })); + if !image_parts.is_empty() { + let mut content_parts = vec![json!({ + "type": "text", + "text": format!( + "Tool result for {} included image content. Use the attached image when answering.", + tool_call_id + ), + })]; + content_parts.extend(image_parts); + messages.push(json!({ + "role": "user", + "content": content_parts, + })); + } + } + return; + } + + let text = content + .as_str() + .map(str::to_string) + .unwrap_or_else(|| content.to_string()); + messages.push(json!({ "role": role, "content": text })); +} + +fn user_content_blocks_to_openai_parts(blocks: &[Value]) -> Vec { + blocks + .iter() + .filter_map( + |block| match block.get("type").and_then(|value| value.as_str()) { + Some("text") => block + .get("text") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(|text| json!({ "type": "text", "text": text })), + Some("image") => anthropic_image_block_to_openai_part(block), + _ => None, + }, + ) + .collect() +} + +fn anthropic_image_block_to_openai_part(block: &Value) -> Option { + let source = block.get("source")?; + let url = match source.get("type").and_then(|value| value.as_str()) { + Some("base64") => { + let media_type = source + .get("media_type") + .and_then(|value| value.as_str()) + .unwrap_or("image/png"); + let data = source.get("data").and_then(|value| value.as_str())?; + format!("data:{};base64,{}", media_type, data) + } + Some("url") => source + .get("url") + .and_then(|value| value.as_str())? + .to_string(), + _ => return None, + }; + Some(json!({ + "type": "image_url", + "image_url": { + "url": url, + "detail": "high", + }, + })) +} + +fn normalize_openai_tool_message_pairs(messages: Vec) -> Vec { + let mut normalized = Vec::with_capacity(messages.len()); + let mut consumed = vec![false; messages.len()]; + + for index in 0..messages.len() { + if consumed[index] { + continue; + } + + let message = &messages[index]; + let tool_call_ids = openai_assistant_tool_call_ids(message); + if !tool_call_ids.is_empty() { + consumed[index] = true; + normalized.push(message.clone()); + + for tool_call_id in tool_call_ids { + if let Some(tool_index) = + find_following_tool_message(&messages, &consumed, index + 1, &tool_call_id) + { + consumed[tool_index] = true; + normalized.push(messages[tool_index].clone()); + } else { + normalized.push(json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": "Tool result unavailable in the prior Claude Code transcript.", + })); + } + } + continue; + } + + consumed[index] = true; + if openai_message_role(message) == Some("tool") { + normalized.push(orphan_tool_message_to_user_message(message)); + } else { + normalized.push(message.clone()); + } + } + + normalized +} + +fn openai_assistant_tool_call_ids(message: &Value) -> Vec { + if openai_message_role(message) != Some("assistant") { + return Vec::new(); + } + + message + .get("tool_calls") + .and_then(|value| value.as_array()) + .map(|tool_calls| { + tool_calls + .iter() + .filter_map(|tool_call| tool_call.get("id").and_then(|value| value.as_str())) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn find_following_tool_message( + messages: &[Value], + consumed: &[bool], + start: usize, + tool_call_id: &str, +) -> Option { + for index in start..messages.len() { + if consumed[index] { + continue; + } + let message = &messages[index]; + if openai_message_role(message) == Some("assistant") { + break; + } + if openai_tool_message_id(message) == Some(tool_call_id) { + return Some(index); + } + } + None +} + +fn orphan_tool_message_to_user_message(message: &Value) -> Value { + let tool_call_id = openai_tool_message_id(message).unwrap_or("unknown"); + let content = message + .get("content") + .and_then(|value| value.as_str()) + .map(str::to_string) + .unwrap_or_else(|| { + message + .get("content") + .cloned() + .unwrap_or(Value::Null) + .to_string() + }); + + json!({ + "role": "user", + "content": format!("Tool result for {}:\n{}", tool_call_id, content), + }) +} + +fn openai_message_role(message: &Value) -> Option<&str> { + message.get("role").and_then(|value| value.as_str()) +} + +fn openai_tool_message_id(message: &Value) -> Option<&str> { + if openai_message_role(message) != Some("tool") { + return None; + } + message.get("tool_call_id").and_then(|value| value.as_str()) +} + +fn flatten_anthropic_content(value: &Value) -> Option { + if let Some(text) = value.as_str() { + return Some(text.to_string()); + } + + value.as_array().map(|blocks| { + blocks + .iter() + .filter_map(|block| { + block + .get("text") + .and_then(|value| value.as_str()) + .or_else(|| block.get("content").and_then(|value| value.as_str())) + }) + .collect::>() + .join("\n\n") + }) +} + +fn assistant_content_to_openai(content: &Value) -> (String, Vec, Option) { + let Some(blocks) = content.as_array() else { + return ( + content + .as_str() + .map(str::to_string) + .unwrap_or_else(|| content.to_string()), + Vec::new(), + None, + ); + }; + + let mut text = Vec::new(); + let mut tool_calls = Vec::new(); + let mut thinking = None; + for block in blocks { + match block.get("type").and_then(|value| value.as_str()) { + Some("text") => { + if let Some(value) = block.get("text").and_then(|value| value.as_str()) { + text.push(value); + } + } + Some("tool_use") => { + let id = block + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("toolu_unknown"); + let name = block + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + tool_calls.push(json!({ + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": input.to_string(), + }, + })); + } + Some("thinking") => { + if let Some(value) = block.get("thinking").and_then(|value| value.as_str()) { + let mut thinking_value = json!({ "content": value }); + if let Some(signature) = block.get("signature").and_then(|value| value.as_str()) + { + thinking_value["signature"] = Value::String(signature.to_string()); + } + thinking = Some(thinking_value); + } + } + _ => {} + } + } + + (text.join("\n\n"), tool_calls, thinking) +} + +fn tool_result_content_to_openai(content: &Value) -> (String, Vec) { + if let Some(text) = content.as_str() { + return (text.to_string(), Vec::new()); + } + if let Some(blocks) = content.as_array() { + let mut text = Vec::new(); + let mut image_parts = Vec::new(); + for block in blocks { + if let Some(value) = block + .get("text") + .and_then(|value| value.as_str()) + .or_else(|| block.get("content").and_then(|value| value.as_str())) + { + text.push(value); + continue; + } + if block.get("type").and_then(|value| value.as_str()) == Some("image") { + if let Some(part) = anthropic_image_block_to_openai_part(block) { + image_parts.push(part); + } + } + } + return (text.join("\n\n"), image_parts); + } + (content.to_string(), Vec::new()) +} + +fn anthropic_tool_to_openai_tool(tool: &Value) -> Option { + let name = tool.get("name")?.as_str()?; + let description = tool + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let parameters = tool + .get("input_schema") + .cloned() + .unwrap_or_else(|| json!({ "type": "object", "properties": {} })); + + Some(json!({ + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": parameters, + }, + })) +} + +fn append_exit_tool(tools: &mut Vec) { + if tools.iter().any(|tool| { + tool.pointer("/function/name") + .and_then(|value| value.as_str()) + == Some(EXIT_TOOL_NAME) + }) { + return; + } + tools.push(json!({ + "type": "function", + "function": { + "name": EXIT_TOOL_NAME, + "description": "Use this when tool mode is active and no remaining tool call is needed. This is the valid way to exit tool mode with a final answer.", + "parameters": { + "type": "object", + "properties": { + "response": { + "type": "string", + "description": "Final response to show the user exactly as written." + } + }, + "required": ["response"] + } + } + })); +} + +fn append_exit_tool_reminder(body: &mut Value) { + let Some(messages) = body + .get_mut("messages") + .and_then(|value| value.as_array_mut()) + else { + return; + }; + messages.push(json!({ + "role": "system", + "content": "Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. Before invoking a tool, carefully evaluate whether it matches the current task. If no available tool is appropriate, or the task is complete, call ExitTool with the final response instead of inventing another tool call.", + })); +} + +fn openai_tool_choice(choice: Option<&Value>) -> Value { + let Some(choice) = choice else { + return Value::String("auto".to_string()); + }; + match choice.get("type").and_then(|value| value.as_str()) { + Some("auto") => Value::String("auto".to_string()), + Some("any") => Value::String("required".to_string()), + Some("tool") => { + let name = choice + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + json!({ + "type": "function", + "function": { "name": name }, + }) + } + _ => Value::String("auto".to_string()), + } +} + +fn copy_number_field(source: &Value, target: &mut Value, key: &str) { + if let Some(value) = source.get(key).filter(|value| value.is_number()) { + target[key] = value.clone(); + } +} + +fn openai_message_text(message: &Value) -> Option { + let content = message.get("content")?; + if let Some(text) = content.as_str() { + return Some(text.to_string()); + } + content.as_array().map(|parts| { + parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(|value| value.as_str()) + .or_else(|| { + if part.get("type").and_then(|value| value.as_str()) == Some("text") { + part.get("content").and_then(|value| value.as_str()) + } else { + None + } + }) + }) + .collect::>() + .join("\n") + }) +} + +fn openai_message_thinking(message: &Value) -> Option { + message + .get("reasoning_content") + .or_else(|| message.get("reasoning")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .or_else(|| { + message + .get("thinking") + .and_then(|value| { + value + .get("content") + .and_then(|content| content.as_str()) + .or_else(|| value.as_str()) + }) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + }) +} + +fn exit_tool_response(arguments: &str) -> Option { + let repaired = repair_tool_arguments(arguments); + serde_json::from_str::(&repaired) + .ok() + .and_then(|value| { + value + .get("response") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) +} + +fn contains_only_exit_tool(message: &Value) -> bool { + let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) else { + return false; + }; + !tool_calls.is_empty() + && tool_calls.iter().all(|call| { + call.pointer("/function/name") + .and_then(|value| value.as_str()) + == Some(EXIT_TOOL_NAME) + }) +} + +fn usage_token(usage: &Value, keys: &[&str]) -> u64 { + keys.iter() + .find_map(|key| usage.get(*key).and_then(|value| value.as_u64())) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credential() -> OpenAiProxyCredential { + OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: "https://api.example.com/v1".to_string(), + model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + } + } + + fn transformers(names: &[&str]) -> ProxyTransformerChain { + ProxyTransformerChain::from_names(names) + } + + #[test] + fn preserves_anthropic_image_blocks_as_openai_image_url_parts() { + let request = json!({ + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": "what is this?" }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abcd" + } + } + ] + }] + }); + + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); + + assert_eq!(converted["messages"][0]["content"][0]["type"], "text"); + assert_eq!(converted["messages"][0]["content"][1]["type"], "image_url"); + assert_eq!( + converted["messages"][0]["content"][1]["image_url"]["url"], + "data:image/png;base64,abcd" + ); + assert_eq!( + converted["messages"][0]["content"][1]["image_url"]["detail"], + "high" + ); + } + + #[test] + fn preserves_tool_result_images_as_follow_up_user_image_parts() { + let request = json!({ + "messages": [ + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_read_image", + "name": "Read", + "input": { "file_path": "attachments/figure.png" } + }] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_read_image", + "content": [ + { "type": "text", "text": "Image read successfully." }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abcd" + } + } + ] + }] + } + ] + }); + + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); + + assert_eq!(converted["messages"][0]["role"], "assistant"); + assert_eq!(converted["messages"][1]["role"], "tool"); + assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_read_image"); + assert_eq!( + converted["messages"][1]["content"], + "Image read successfully." + ); + assert_eq!(converted["messages"][2]["role"], "user"); + assert_eq!(converted["messages"][2]["content"][0]["type"], "text"); + assert_eq!(converted["messages"][2]["content"][1]["type"], "image_url"); + assert_eq!( + converted["messages"][2]["content"][1]["image_url"]["url"], + "data:image/png;base64,abcd" + ); + assert_eq!( + converted["messages"][2]["content"][1]["image_url"]["detail"], + "high" + ); + } + + #[test] + fn preserves_assistant_thinking_for_provider_context() { + let request = json!({ + "messages": [{ + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "I inspected the files.", + "signature": "sig_1" + }, + { + "type": "text", + "text": "Done." + } + ] + }] + }); + + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); + + assert_eq!( + converted["messages"][0]["thinking"]["content"], + "I inspected the files." + ); + assert_eq!(converted["messages"][0]["thinking"]["signature"], "sig_1"); + } + + #[test] + fn adds_exit_tool_when_tool_choice_requires_a_tool() { + let request = json!({ + "messages": [{ "role": "user", "content": "finish" }], + "tool_choice": { "type": "any" }, + "tools": [{ + "name": "Read", + "description": "Read a file", + "input_schema": { "type": "object" } + }] + }); + + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap(); + let tool_names = converted["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| { + tool.pointer("/function/name") + .and_then(|value| value.as_str()) + }) + .collect::>(); + + assert!(tool_names.contains(&"Read")); + assert!(tool_names.contains(&EXIT_TOOL_NAME)); + assert_eq!(converted["tool_choice"], "required"); + } + + #[test] + fn tooluse_transformer_forces_exit_tool_like_ccr() { + let request = json!({ + "messages": [{ "role": "user", "content": "finish" }], + "tools": [{ + "name": "Read", + "description": "Read a file", + "input_schema": { "type": "object" } + }] + }); + + let converted = + anthropic_to_openai_request(&request, &credential(), &transformers(&["tooluse"])) + .unwrap(); + let tool_names = converted["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| { + tool.pointer("/function/name") + .and_then(|value| value.as_str()) + }) + .collect::>(); + + assert_eq!(converted["tool_choice"], "required"); + assert!(tool_names.contains(&"Read")); + assert!(tool_names.contains(&EXIT_TOOL_NAME)); + assert!(converted["messages"] + .as_array() + .unwrap() + .iter() + .any(|message| message + .get("content") + .and_then(|value| value.as_str()) + .is_some_and(|content| content.contains("Tool mode is active")))); + } + + #[test] + fn converts_exit_tool_response_to_final_text() { + let request = json!({ "model": "claude-sonnet-4" }); + let response = json!({ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_exit", + "type": "function", + "function": { + "name": "ExitTool", + "arguments": "{\"response\":\"done\"}" + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { "prompt_tokens": 5, "completion_tokens": 2 } + }); + + let converted = openai_to_anthropic_message(&request, &response, &credential()).unwrap(); + + assert_eq!(converted["stop_reason"], "end_turn"); + assert_eq!(converted["content"][0]["type"], "text"); + assert_eq!(converted["content"][0]["text"], "done"); + } +} diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs b/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs new file mode 100644 index 0000000..41fb669 --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy/providers.rs @@ -0,0 +1,214 @@ +use super::transformers::ProxyTransformerChain; +use super::OpenAiProxyCredential; +use serde_json::{json, Value}; + +const DEEPSEEK_MAX_TOKENS: u64 = 8192; + +pub(super) fn apply_provider_request_transforms( + openai_request: &mut Value, + anthropic_request: &Value, + credential: &OpenAiProxyCredential, + wants_stream: bool, + transformers: &ProxyTransformerChain, +) { + if transformers.has_cleancache() { + clean_cache_control(openai_request); + } + + if wants_stream && transformers.has_streamoptions() { + openai_request["stream_options"] = json!({ "include_usage": true }); + } + + if transformers.has_deepseek() { + cap_number_field(openai_request, "max_tokens", DEEPSEEK_MAX_TOKENS); + } + + apply_reasoning_budget(openai_request, anthropic_request); + apply_max_completion_tokens_compat(openai_request, credential); + clean_null_optional_fields(openai_request); +} + +fn cap_number_field(body: &mut Value, key: &str, max: u64) { + let Some(value) = body.get(key).and_then(|value| value.as_u64()) else { + return; + }; + if value > max { + body[key] = Value::Number(max.into()); + } +} + +fn apply_reasoning_budget(openai_request: &mut Value, anthropic_request: &Value) { + let Some(thinking) = anthropic_request.get("thinking") else { + return; + }; + if thinking.get("type").and_then(|value| value.as_str()) != Some("enabled") { + return; + } + let Some(budget_tokens) = thinking + .get("budget_tokens") + .and_then(|value| value.as_u64()) + else { + return; + }; + if budget_tokens > 0 { + openai_request["reasoning"] = json!({ "max_tokens": budget_tokens }); + } +} + +fn apply_max_completion_tokens_compat( + openai_request: &mut Value, + credential: &OpenAiProxyCredential, +) { + if !uses_max_completion_tokens(credential) { + return; + } + let Some(max_tokens) = openai_request.get("max_tokens").cloned() else { + return; + }; + openai_request["max_completion_tokens"] = max_tokens; + if let Some(object) = openai_request.as_object_mut() { + object.remove("max_tokens"); + } +} + +fn uses_max_completion_tokens(credential: &OpenAiProxyCredential) -> bool { + let base_url = credential.base_url.to_ascii_lowercase(); + let model = credential.model.to_ascii_lowercase(); + let is_openai = base_url.contains("api.openai.com") || base_url.contains("openai.azure.com"); + is_openai + && (model.starts_with("o1") + || model.starts_with("o3") + || model.starts_with("o4") + || model.starts_with("gpt-5")) +} + +fn clean_cache_control(value: &mut Value) { + match value { + Value::Array(values) => { + for value in values { + clean_cache_control(value); + } + } + Value::Object(object) => { + object.remove("cache_control"); + for value in object.values_mut() { + clean_cache_control(value); + } + } + _ => {} + } +} + +fn clean_null_optional_fields(value: &mut Value) { + let Some(object) = value.as_object_mut() else { + return; + }; + for key in ["tool_choice", "stop", "stream_options", "reasoning"] { + if object.get(key).is_some_and(Value::is_null) { + object.remove(key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential { + OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + } + } + + #[test] + fn adds_usage_stream_options_for_streaming_requests() { + let mut body = json!({ "stream": true }); + apply_provider_request_transforms( + &mut body, + &json!({}), + &credential("https://api.example.com/v1", "qwen-test"), + true, + &ProxyTransformerChain::from_names(&["streamoptions"]), + ); + + assert_eq!(body["stream_options"]["include_usage"], true); + } + + #[test] + fn caps_deepseek_max_tokens() { + let mut body = json!({ "max_tokens": 20000 }); + apply_provider_request_transforms( + &mut body, + &json!({}), + &credential("https://api.deepseek.com", "deepseek-chat"), + false, + &ProxyTransformerChain::from_names(&["deepseek"]), + ); + + assert_eq!(body["max_tokens"], DEEPSEEK_MAX_TOKENS); + } + + #[test] + fn copies_anthropic_thinking_budget_as_reasoning() { + let mut body = json!({}); + apply_provider_request_transforms( + &mut body, + &json!({ + "thinking": { + "type": "enabled", + "budget_tokens": 4096 + } + }), + &credential("https://api.example.com/v1", "qwen-test"), + false, + &ProxyTransformerChain::from_names(&[]), + ); + + assert_eq!(body["reasoning"]["max_tokens"], 4096); + } + + #[test] + fn converts_openai_reasoning_models_to_max_completion_tokens() { + let mut body = json!({ "max_tokens": 12000 }); + apply_provider_request_transforms( + &mut body, + &json!({}), + &credential("https://api.openai.com/v1", "o3"), + false, + &ProxyTransformerChain::from_names(&[]), + ); + + assert!(body.get("max_tokens").is_none()); + assert_eq!(body["max_completion_tokens"], 12000); + } + + #[test] + fn strips_cache_control_from_openai_compatible_requests() { + let mut body = json!({ + "messages": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "hello", + "cache_control": { "type": "ephemeral" } + }] + }] + }); + + apply_provider_request_transforms( + &mut body, + &json!({}), + &credential("https://api.example.com/v1", "qwen-test"), + false, + &ProxyTransformerChain::from_names(&["cleancache"]), + ); + + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs b/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs new file mode 100644 index 0000000..18b0fed --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy/stream.rs @@ -0,0 +1,925 @@ +use super::tools::{normalized_tool_call_id, repair_tool_arguments}; +use super::{http_response, OpenAiProxyCredential}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; + +#[derive(Default)] +struct OpenAiStreamState { + message_started: bool, + completed: bool, + message_id: Option, + model: Option, + next_block_index: usize, + text_block_index: Option, + thinking_block_index: Option, + tool_blocks: HashMap, + stop_reason: Option, + output_tokens: u64, +} + +#[derive(Default)] +struct StreamToolBlock { + id: Option, + name: Option, + buffered_arguments: String, +} + +pub(super) async fn stream_openai_sse_to_anthropic( + stream: &mut TcpStream, + mut response: reqwest::Response, + anthropic_request: &Value, + credential: &OpenAiProxyCredential, +) -> Result<(), String> { + stream + .write_all(streaming_http_headers().as_bytes()) + .await + .map_err(|err| format!("Failed to write proxy stream headers: {}", err))?; + + let mut state = OpenAiStreamState::default(); + let mut buffer = String::new(); + while let Some(chunk) = match response.chunk().await { + Ok(chunk) => chunk, + Err(err) => { + let rendered = + anthropic_stream_error_sse(&format!("Provider stream ended unexpectedly: {}", err)); + let _ = write_stream_body(stream, &rendered, "provider stream error").await; + return Ok(()); + } + } { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + while let Some((event, rest)) = take_next_sse_event(&buffer) { + buffer = rest; + let rendered = + openai_sse_event_to_anthropic(&mut state, &event, anthropic_request, credential); + if !write_stream_body(stream, &rendered, "proxy stream event").await { + return Ok(()); + } + } + } + + if !buffer.trim().is_empty() { + let rendered = + openai_sse_event_to_anthropic(&mut state, &buffer, anthropic_request, credential); + if !write_stream_body(stream, &rendered, "final proxy stream event").await { + return Ok(()); + } + } + + let rendered = finish_anthropic_stream(&mut state); + let _ = write_stream_body(stream, &rendered, "proxy stream completion").await; + Ok(()) +} + +fn streaming_http_headers() -> String { + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n" + .to_string() +} + +async fn write_stream_body(stream: &mut TcpStream, body: &str, context: &str) -> bool { + if body.is_empty() { + return true; + } + match stream.write_all(body.as_bytes()).await { + Ok(()) => true, + Err(err) => { + eprintln!("[anthropic-proxy] failed to write {}: {}", context, err); + false + } + } +} + +fn anthropic_stream_error_sse(message: &str) -> String { + let mut body = String::new(); + push_sse( + &mut body, + "error", + &json!({ + "type": "error", + "error": { + "type": "api_error", + "message": message, + }, + }), + ); + body +} + +fn take_next_sse_event(buffer: &str) -> Option<(String, String)> { + if let Some(index) = buffer.find("\n\n") { + let event = buffer[..index].to_string(); + let rest = buffer[index + 2..].to_string(); + return Some((event, rest)); + } + if let Some(index) = buffer.find("\r\n\r\n") { + let event = buffer[..index].to_string(); + let rest = buffer[index + 4..].to_string(); + return Some((event, rest)); + } + None +} + +fn openai_sse_event_to_anthropic( + state: &mut OpenAiStreamState, + event: &str, + anthropic_request: &Value, + credential: &OpenAiProxyCredential, +) -> String { + let Some(data) = sse_event_data(event) else { + return String::new(); + }; + if data.trim() == "[DONE]" { + return finish_anthropic_stream(state); + } + let Ok(chunk) = serde_json::from_str::(&data) else { + return String::new(); + }; + openai_stream_chunk_to_anthropic(state, &chunk, anthropic_request, credential) +} + +fn sse_event_data(event: &str) -> Option { + let mut parts = Vec::new(); + for line in event.lines() { + let line = line.trim_end_matches('\r'); + if let Some(data) = line.strip_prefix("data:") { + parts.push(data.trim_start()); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + +fn openai_stream_chunk_to_anthropic( + state: &mut OpenAiStreamState, + chunk: &Value, + anthropic_request: &Value, + credential: &OpenAiProxyCredential, +) -> String { + let mut body = String::new(); + ensure_stream_message_started(state, &mut body, chunk, anthropic_request, credential); + + if let Some(usage) = chunk.get("usage") { + state.output_tokens = usage_token( + usage, + &[ + "completion_tokens", + "output_tokens", + "completion_token_count", + ], + ); + } + + let Some(choice) = chunk + .get("choices") + .and_then(|value| value.as_array()) + .and_then(|choices| choices.first()) + else { + return body; + }; + let delta = choice.get("delta").unwrap_or(&Value::Null); + + if let Some(reasoning) = delta_text(delta, &["reasoning_content", "reasoning"]) { + push_stream_text_delta(state, &mut body, "thinking", &reasoning); + } + if let Some(thinking) = delta + .get("thinking") + .and_then(|value| { + value + .get("content") + .and_then(|content| content.as_str()) + .or_else(|| value.as_str()) + }) + .filter(|value| !value.is_empty()) + { + push_stream_text_delta(state, &mut body, "thinking", thinking); + } + if let Some(content) = delta_text(delta, &["content"]) { + push_stream_text_delta(state, &mut body, "text", &content); + } + if let Some(tool_calls) = delta.get("tool_calls").and_then(|value| value.as_array()) { + for call in tool_calls { + push_stream_tool_delta(state, call); + } + } + + if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) { + if !finish_reason.is_empty() { + state.stop_reason = Some(map_openai_finish_reason(finish_reason).to_string()); + } + } + + body +} + +fn ensure_stream_message_started( + state: &mut OpenAiStreamState, + body: &mut String, + chunk: &Value, + anthropic_request: &Value, + credential: &OpenAiProxyCredential, +) { + if state.message_started { + return; + } + state.message_started = true; + state.message_id = chunk + .get("id") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| Some(format!("msg_{}", uuid::Uuid::new_v4().simple()))); + state.model = anthropic_request + .get("model") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + chunk + .get("model") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + .or_else(|| Some(credential.model.clone())); + + push_sse( + body, + "message_start", + &json!({ + "type": "message_start", + "message": { + "id": state.message_id.clone().unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())), + "type": "message", + "role": "assistant", + "model": state.model.clone().unwrap_or_else(|| credential.model.clone()), + "content": [], + "stop_reason": Value::Null, + "stop_sequence": Value::Null, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + }, + }, + }), + ); +} + +fn delta_text(delta: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| delta.get(*key).and_then(|value| value.as_str())) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn push_stream_text_delta( + state: &mut OpenAiStreamState, + body: &mut String, + block_type: &str, + text: &str, +) { + if block_type != "thinking" { + close_thinking_block_if_open(state, body); + } + + let block_index = if block_type == "thinking" { + if let Some(index) = state.thinking_block_index { + index + } else { + let index = state.next_block_index; + state.next_block_index += 1; + state.thinking_block_index = Some(index); + push_sse( + body, + "content_block_start", + &json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "thinking", + "thinking": "", + }, + }), + ); + index + } + } else if let Some(index) = state.text_block_index { + index + } else { + let index = state.next_block_index; + state.next_block_index += 1; + state.text_block_index = Some(index); + push_sse( + body, + "content_block_start", + &json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "text", + "text": "", + }, + }), + ); + index + }; + + let (delta_type, key) = if block_type == "thinking" { + ("thinking_delta", "thinking") + } else { + ("text_delta", "text") + }; + push_sse( + body, + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": block_index, + "delta": { + "type": delta_type, + key: text, + }, + }), + ); +} + +fn push_stream_tool_delta(state: &mut OpenAiStreamState, call: &Value) { + let openai_index = call + .get("index") + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let block = state.tool_blocks.entry(openai_index).or_default(); + if let Some(id) = call.get("id").and_then(|value| value.as_str()) { + block.id = Some(normalized_tool_call_id(Some(id))); + } + let function = call.get("function").unwrap_or(&Value::Null); + if let Some(name) = function.get("name").and_then(|value| value.as_str()) { + if !name.is_empty() { + block.name = Some(name.to_string()); + } + } + if let Some(arguments) = function.get("arguments").and_then(|value| value.as_str()) { + block.buffered_arguments.push_str(arguments); + } +} + +fn close_thinking_block_if_open(state: &mut OpenAiStreamState, body: &mut String) { + if let Some(index) = state.thinking_block_index.take() { + push_sse( + body, + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "signature_delta", + "signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()), + }, + }), + ); + push_content_block_stop(body, index); + } +} + +fn finish_anthropic_stream(state: &mut OpenAiStreamState) -> String { + if state.completed { + return String::new(); + } + state.completed = true; + let mut body = String::new(); + if !state.message_started { + state.message_started = true; + let message_id = format!("msg_{}", uuid::Uuid::new_v4().simple()); + state.message_id = Some(message_id.clone()); + push_sse( + &mut body, + "message_start", + &json!({ + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "model": state.model.clone().unwrap_or_else(|| "claude-prism-proxy".to_string()), + "content": [], + "stop_reason": Value::Null, + "stop_sequence": Value::Null, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + }, + }, + }), + ); + } + close_thinking_block_if_open(state, &mut body); + if let Some(index) = state.text_block_index.take() { + push_content_block_stop(&mut body, index); + } + + let mut tool_blocks = state + .tool_blocks + .iter() + .map(|(openai_index, block)| (*openai_index, block)) + .collect::>(); + tool_blocks.sort_by_key(|(openai_index, _)| *openai_index); + let exit_tool_response = if tool_blocks + .iter() + .all(|(_, block)| block.name.as_deref() == Some("ExitTool")) + { + tool_blocks + .iter() + .find_map(|(_, block)| exit_tool_response(&block.buffered_arguments)) + } else { + None + }; + if let Some(response) = exit_tool_response { + push_stream_text_delta(state, &mut body, "text", &response); + if let Some(index) = state.text_block_index.take() { + push_content_block_stop(&mut body, index); + } + state.tool_blocks.clear(); + state.stop_reason = Some("end_turn".to_string()); + } + let mut tool_blocks = state + .tool_blocks + .iter() + .map(|(openai_index, block)| (*openai_index, block)) + .collect::>(); + tool_blocks.sort_by_key(|(openai_index, _)| *openai_index); + for (_, block) in tool_blocks { + let index = state.next_block_index; + state.next_block_index += 1; + push_sse( + &mut body, + "content_block_start", + &json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "tool_use", + "id": block.id.clone().unwrap_or_else(|| normalized_tool_call_id(None)), + "name": block.name.clone().unwrap_or_else(|| "unknown".to_string()), + "input": {}, + }, + }), + ); + let repaired_arguments = repair_tool_arguments(&block.buffered_arguments); + if repaired_arguments != "{}" || !block.buffered_arguments.trim().is_empty() { + push_sse( + &mut body, + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "input_json_delta", + "partial_json": repaired_arguments, + }, + }), + ); + } + push_content_block_stop(&mut body, index); + } + let stop_reason = if state.tool_blocks.is_empty() { + state + .stop_reason + .clone() + .unwrap_or_else(|| "end_turn".to_string()) + } else { + "tool_use".to_string() + }; + push_sse( + &mut body, + "message_delta", + &json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": Value::Null, + }, + "usage": { + "output_tokens": state.output_tokens, + }, + }), + ); + push_sse( + &mut body, + "message_stop", + &json!({ "type": "message_stop" }), + ); + body +} + +fn exit_tool_response(arguments: &str) -> Option { + let repaired = repair_tool_arguments(arguments); + serde_json::from_str::(&repaired) + .ok() + .and_then(|value| { + value + .get("response") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) +} + +fn push_content_block_stop(body: &mut String, index: usize) { + push_sse( + body, + "content_block_stop", + &json!({ + "type": "content_block_stop", + "index": index, + }), + ); +} + +fn map_openai_finish_reason(reason: &str) -> &str { + match reason { + "length" => "max_tokens", + "tool_calls" => "tool_use", + _ => "end_turn", + } +} + +pub(super) fn sse_response(message: &Value) -> String { + let content = message + .get("content") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let input_tokens = message + .pointer("/usage/input_tokens") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let output_tokens = message + .pointer("/usage/output_tokens") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + + let start = json!({ + "type": "message_start", + "message": { + "id": message.get("id").cloned().unwrap_or_else(|| json!(format!("msg_{}", uuid::Uuid::new_v4().simple()))), + "type": "message", + "role": "assistant", + "model": message.get("model").cloned().unwrap_or_else(|| json!("claude-prism-proxy")), + "content": [], + "stop_reason": Value::Null, + "stop_sequence": Value::Null, + "usage": { + "input_tokens": input_tokens, + "output_tokens": 0, + }, + }, + }); + + let mut body = String::new(); + push_sse(&mut body, "message_start", &start); + for (index, block) in content.iter().enumerate() { + let block_type = block + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("text"); + match block_type { + "tool_use" => { + let content_block = json!({ + "type": "tool_use", + "id": block.get("id").cloned().unwrap_or_else(|| json!(format!("toolu_{}", uuid::Uuid::new_v4().simple()))), + "name": block.get("name").cloned().unwrap_or_else(|| json!("unknown")), + "input": {}, + }); + push_sse( + &mut body, + "content_block_start", + &json!({ + "type": "content_block_start", + "index": index, + "content_block": content_block, + }), + ); + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + push_sse( + &mut body, + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "input_json_delta", + "partial_json": input.to_string(), + }, + }), + ); + } + "thinking" => { + push_text_like_sse_block( + &mut body, + index, + "thinking", + block + .get("thinking") + .and_then(|value| value.as_str()) + .unwrap_or_default(), + ); + } + _ => { + push_text_like_sse_block( + &mut body, + index, + "text", + block + .get("text") + .and_then(|value| value.as_str()) + .unwrap_or_default(), + ); + } + } + push_content_block_stop(&mut body, index); + } + push_sse( + &mut body, + "message_delta", + &json!({ + "type": "message_delta", + "delta": { + "stop_reason": message.get("stop_reason").cloned().unwrap_or_else(|| json!("end_turn")), + "stop_sequence": Value::Null, + }, + "usage": { + "output_tokens": output_tokens, + }, + }), + ); + push_sse( + &mut body, + "message_stop", + &json!({ "type": "message_stop" }), + ); + + http_response( + 200, + "text/event-stream; charset=utf-8", + &format!("{}{}", body, "\n"), + ) +} + +fn push_text_like_sse_block(body: &mut String, index: usize, block_type: &str, text: &str) { + let content_block = if block_type == "thinking" { + json!({ + "type": "thinking", + "thinking": "", + }) + } else { + json!({ + "type": "text", + "text": "", + }) + }; + push_sse( + body, + "content_block_start", + &json!({ + "type": "content_block_start", + "index": index, + "content_block": content_block, + }), + ); + if !text.is_empty() { + let delta_type = if block_type == "thinking" { + "thinking_delta" + } else { + "text_delta" + }; + let delta_key = if block_type == "thinking" { + "thinking" + } else { + "text" + }; + push_sse( + body, + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": delta_type, + delta_key: text, + }, + }), + ); + } + if block_type == "thinking" { + push_sse( + body, + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "signature_delta", + "signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()), + }, + }), + ); + } +} + +fn push_sse(body: &mut String, event: &str, data: &Value) { + body.push_str("event: "); + body.push_str(event); + body.push('\n'); + body.push_str("data: "); + body.push_str(&data.to_string()); + body.push_str("\n\n"); +} + +fn usage_token(usage: &Value, keys: &[&str]) -> u64 { + keys.iter() + .find_map(|key| usage.get(*key).and_then(|value| value.as_u64())) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credential() -> OpenAiProxyCredential { + OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: "https://api.example.com/v1".to_string(), + model: "qwen-test".to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + } + } + + #[test] + fn renders_provider_stream_errors_as_anthropic_sse_errors() { + let rendered = anthropic_stream_error_sse("provider stream broke"); + + assert!(rendered.contains("event: error")); + assert!(rendered.contains("\"type\":\"error\"")); + assert!(rendered.contains("\"type\":\"api_error\"")); + assert!(rendered.contains("\"message\":\"provider stream broke\"")); + } + + #[test] + fn streams_openai_text_delta_as_anthropic_sse() { + let request = json!({ "model": "claude-sonnet-4" }); + let mut state = OpenAiStreamState::default(); + let chunk = json!({ + "id": "chatcmpl_1", + "model": "qwen-test", + "choices": [{ + "delta": { "content": "Hello" }, + "finish_reason": null + }] + }); + + let first = openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential()); + let done = finish_anthropic_stream(&mut state); + let combined = format!("{}{}", first, done); + + assert!(combined.contains("event: message_start")); + assert!(combined.contains("\"model\":\"claude-sonnet-4\"")); + assert!(combined.contains("\"type\":\"text_delta\"")); + assert!(combined.contains("\"text\":\"Hello\"")); + assert!(combined.contains("\"stop_reason\":\"end_turn\"")); + assert!(finish_anthropic_stream(&mut state).is_empty()); + } + + #[test] + fn streams_reasoning_content_as_thinking_delta() { + let request = json!({ "model": "claude-sonnet-4" }); + let mut state = OpenAiStreamState::default(); + let chunk = json!({ + "id": "chatcmpl_1", + "choices": [{ + "delta": { "reasoning_content": "I should inspect files." }, + "finish_reason": null + }] + }); + + let rendered = + openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential()); + + assert!(rendered.contains("\"type\":\"thinking\"")); + assert!(rendered.contains("\"type\":\"thinking_delta\"")); + assert!(rendered.contains("\"thinking\":\"I should inspect files.\"")); + let done = finish_anthropic_stream(&mut state); + assert!(done.contains("\"type\":\"signature_delta\"")); + } + + #[test] + fn buffers_and_repairs_streamed_tool_arguments() { + let request = json!({ "model": "claude-sonnet-4" }); + let mut state = OpenAiStreamState::default(); + let first_chunk = json!({ + "id": "chatcmpl_1", + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": { + "name": "Read", + "arguments": "{\"file_path\":" + } + }] + }, + "finish_reason": null + }] + }); + let second_chunk = json!({ + "id": "chatcmpl_1", + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "\"main.tex\"" } + }] + }, + "finish_reason": "tool_calls" + }] + }); + + let first = + openai_stream_chunk_to_anthropic(&mut state, &first_chunk, &request, &credential()); + let second = + openai_stream_chunk_to_anthropic(&mut state, &second_chunk, &request, &credential()); + let done = finish_anthropic_stream(&mut state); + let combined = format!("{}{}{}", first, second, done); + + assert!(!first.contains("\"type\":\"tool_use\"")); + assert!(!second.contains("\"type\":\"tool_use\"")); + assert!(combined.contains("\"type\":\"tool_use\"")); + assert!(combined.contains("\"id\":\"call_1\"")); + assert!(combined.contains("\"name\":\"Read\"")); + assert!(combined.contains("\"type\":\"input_json_delta\"")); + assert!(combined.contains("{\\\"file_path\\\":\\\"main.tex\\\"}")); + assert!(combined.contains("\"stop_reason\":\"tool_use\"")); + } + + #[test] + fn normalizes_numeric_streamed_tool_call_ids() { + let request = json!({ "model": "claude-sonnet-4" }); + let mut state = OpenAiStreamState::default(); + let chunk = json!({ + "id": "chatcmpl_1", + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": "123", + "type": "function", + "function": { + "name": "Read", + "arguments": "{\"file_path\":\"main.tex\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + }); + + openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential()); + let done = finish_anthropic_stream(&mut state); + + assert!(done.contains("\"id\":\"call_")); + assert!(!done.contains("\"id\":\"123\"")); + } + + #[test] + fn streams_exit_tool_as_final_text() { + let request = json!({ "model": "claude-sonnet-4" }); + let mut state = OpenAiStreamState::default(); + let chunk = json!({ + "id": "chatcmpl_1", + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_exit", + "type": "function", + "function": { + "name": "ExitTool", + "arguments": "{\"response\":\"all done\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + }); + + openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential()); + let done = finish_anthropic_stream(&mut state); + + assert!(done.contains("\"type\":\"text_delta\"")); + assert!(done.contains("\"text\":\"all done\"")); + assert!(done.contains("\"stop_reason\":\"end_turn\"")); + assert!(!done.contains("\"type\":\"tool_use\"")); + } +} diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs b/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs new file mode 100644 index 0000000..6a114f5 --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy/tools.rs @@ -0,0 +1,561 @@ +use serde_json::{json, Value}; + +pub(super) fn repaired_tool_arguments_value(arguments: &str) -> Value { + serde_json::from_str::(&repair_tool_arguments(arguments)).unwrap_or_else(|_| json!({})) +} + +pub(super) fn normalized_tool_call_id(id: Option<&str>) -> String { + let id = id.unwrap_or_default().trim(); + if id.is_empty() || id.chars().all(|ch| ch.is_ascii_digit()) { + format!("call_{}", uuid::Uuid::new_v4().simple()) + } else { + id.to_string() + } +} + +pub(super) fn repair_tool_arguments(arguments: &str) -> String { + let trimmed = trim_code_fence(arguments.trim()); + if trimmed.is_empty() || trimmed == "{}" { + return "{}".to_string(); + } + + let mut candidates = Vec::new(); + push_candidate(&mut candidates, trimmed.to_string()); + if let Some(extracted) = extract_json_like(trimmed) { + push_candidate(&mut candidates, extracted); + } + + let seeds = candidates.clone(); + for candidate in seeds { + let without_comments = strip_json_comments(&candidate); + push_candidate(&mut candidates, without_comments.clone()); + + let without_trailing_commas = remove_trailing_commas(&without_comments); + push_candidate(&mut candidates, without_trailing_commas.clone()); + + let json5_like = + normalize_single_quoted_strings("e_unquoted_object_keys(&without_trailing_commas)); + push_candidate(&mut candidates, json5_like.clone()); + + if let Some(with_commas) = insert_missing_commas_between_fields(&json5_like) { + push_candidate(&mut candidates, with_commas.clone()); + if let Some(balanced) = repair_balanced_json(with_commas) { + push_candidate(&mut candidates, balanced); + } + } + if let Some(balanced) = repair_balanced_json(json5_like) { + push_candidate(&mut candidates, balanced); + } + if let Some(balanced) = repair_balanced_json(without_trailing_commas) { + push_candidate(&mut candidates, balanced); + } + } + + for candidate in candidates { + if let Some(repaired) = parse_tool_arguments_candidate(&candidate) { + return repaired; + } + } + + "{}".to_string() +} + +fn push_candidate(candidates: &mut Vec, value: String) { + let value = value.trim().to_string(); + if value.is_empty() || candidates.iter().any(|candidate| candidate == &value) { + return; + } + candidates.push(value); +} + +fn parse_tool_arguments_candidate(value: &str) -> Option { + serde_json::from_str::(value) + .ok() + .or_else(|| serde_yaml::from_str::(value).ok()) + .and_then(canonical_tool_arguments) +} + +fn canonical_tool_arguments(value: Value) -> Option { + match value { + Value::Object(map) => { + if map.keys().any(|key| key.contains(':')) { + return None; + } + Some(Value::Object(map).to_string()) + } + Value::Array(_) => Some(value.to_string()), + _ => None, + } +} + +fn trim_code_fence(value: &str) -> &str { + let value = value.trim(); + if !value.starts_with("```") { + return value; + } + let Some(first_newline) = value.find('\n') else { + return value; + }; + let value = &value[first_newline + 1..]; + value + .strip_suffix("```") + .map(str::trim) + .unwrap_or(value.trim()) +} + +fn extract_json_like(value: &str) -> Option { + let object_start = value.find('{'); + let array_start = value.find('['); + let start = match (object_start, array_start) { + (Some(object), Some(array)) => object.min(array), + (Some(object), None) => object, + (None, Some(array)) => array, + (None, None) => return None, + }; + let end = value.rfind('}').or_else(|| value.rfind(']'))?; + if end <= start { + return None; + } + Some(value[start..=end].to_string()) +} + +fn repair_balanced_json(value: String) -> Option { + let mut output = String::with_capacity(value.len() + 8); + let mut stack = Vec::new(); + let mut in_string = false; + let mut escaped = false; + + for ch in value.chars() { + output.push(ch); + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => in_string = true, + '{' => stack.push('}'), + '[' => stack.push(']'), + '}' | ']' => { + if stack.last().copied() == Some(ch) { + stack.pop(); + } + } + _ => {} + } + } + + if in_string { + output.push('"'); + } + while let Some(ch) = stack.pop() { + output.push(ch); + } + Some(output) +} + +fn remove_trailing_commas(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + let mut in_string = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if in_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + if ch == '"' { + in_string = true; + output.push(ch); + continue; + } + + if ch == ',' { + let mut lookahead = chars.clone(); + while matches!(lookahead.peek(), Some(next) if next.is_whitespace()) { + lookahead.next(); + } + if matches!(lookahead.peek(), Some('}' | ']')) { + continue; + } + } + output.push(ch); + } + + output +} + +fn strip_json_comments(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + let mut in_double_string = false; + let mut in_single_string = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if in_double_string || in_single_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if in_double_string && ch == '"' { + in_double_string = false; + } else if in_single_string && ch == '\'' { + in_single_string = false; + } + continue; + } + + match ch { + '"' => { + in_double_string = true; + output.push(ch); + } + '\'' => { + in_single_string = true; + output.push(ch); + } + '/' if chars.peek() == Some(&'/') => { + chars.next(); + for next in chars.by_ref() { + if next == '\n' { + output.push('\n'); + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut previous = '\0'; + for next in chars.by_ref() { + if previous == '*' && next == '/' { + break; + } + previous = next; + } + } + _ => output.push(ch), + } + } + + output +} + +fn quote_unquoted_object_keys(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 16); + let chars = value.chars().collect::>(); + let mut index = 0; + let mut in_double_string = false; + let mut in_single_string = false; + let mut escaped = false; + let mut expects_key = false; + + while index < chars.len() { + let ch = chars[index]; + if in_double_string || in_single_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if in_double_string && ch == '"' { + in_double_string = false; + } else if in_single_string && ch == '\'' { + in_single_string = false; + } + index += 1; + continue; + } + + match ch { + '"' => { + in_double_string = true; + output.push(ch); + expects_key = false; + index += 1; + } + '\'' => { + in_single_string = true; + output.push(ch); + expects_key = false; + index += 1; + } + '{' | ',' => { + expects_key = true; + output.push(ch); + index += 1; + } + '}' | ']' => { + expects_key = false; + output.push(ch); + index += 1; + } + ch if expects_key && ch.is_whitespace() => { + output.push(ch); + index += 1; + } + ch if expects_key && is_identifier_start(ch) => { + let start = index; + index += 1; + while index < chars.len() && is_identifier_continue(chars[index]) { + index += 1; + } + let mut lookahead = index; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if lookahead < chars.len() && chars[lookahead] == ':' { + output.push('"'); + for key_ch in &chars[start..index] { + output.push(*key_ch); + } + output.push('"'); + expects_key = false; + } else { + for key_ch in &chars[start..index] { + output.push(*key_ch); + } + expects_key = false; + } + } + _ => { + output.push(ch); + index += 1; + } + } + } + + output +} + +fn normalize_single_quoted_strings(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + let mut in_double_string = false; + let mut in_single_string = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if in_double_string { + output.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_double_string = false; + } + continue; + } + + if in_single_string { + if escaped { + match ch { + '\'' => output.push('\''), + '"' => { + output.push('\\'); + output.push('"'); + } + '\\' => output.push('\\'), + _ => { + output.push('\\'); + output.push(ch); + } + } + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '\'' { + output.push('"'); + in_single_string = false; + } else if ch == '"' { + output.push('\\'); + output.push('"'); + } else { + output.push(ch); + } + continue; + } + + if ch == '"' { + in_double_string = true; + output.push(ch); + } else if ch == '\'' { + in_single_string = true; + output.push('"'); + } else { + output.push(ch); + } + } + + if in_single_string { + output.push('"'); + } + output +} + +fn insert_missing_commas_between_fields(value: &str) -> Option { + let mut output = String::with_capacity(value.len() + 8); + let chars = value.chars().collect::>(); + let mut index = 0; + let mut changed = false; + let mut in_string = false; + let mut escaped = false; + + while index < chars.len() { + let ch = chars[index]; + output.push(ch); + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + index += 1; + continue; + } + + if ch == '"' { + in_string = true; + index += 1; + continue; + } + + if matches!(ch, '"' | '}' | ']' | '0'..='9' | 'e' | 'E' | 'l') { + let mut lookahead = index + 1; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if lookahead < chars.len() + && chars[lookahead] == '"' + && previous_non_whitespace(&chars, index) != Some(':') + { + output.push(','); + changed = true; + } + } + index += 1; + } + + changed.then_some(output) +} + +fn previous_non_whitespace(chars: &[char], index: usize) -> Option { + if index == 0 { + return None; + } + let mut cursor = index - 1; + loop { + if !chars[cursor].is_whitespace() { + return Some(chars[cursor]); + } + if cursor == 0 { + return None; + } + cursor -= 1; + } +} + +fn is_identifier_start(ch: char) -> bool { + ch == '_' || ch == '$' || ch.is_ascii_alphabetic() +} + +fn is_identifier_continue(ch: char) -> bool { + is_identifier_start(ch) || ch.is_ascii_digit() || ch == '-' || ch == '.' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repairs_partial_tool_arguments() { + assert_eq!( + repair_tool_arguments("{\"file_path\":\"main.tex\""), + "{\"file_path\":\"main.tex\"}" + ); + } + + #[test] + fn repairs_fenced_tool_arguments() { + assert_eq!( + repair_tool_arguments("```json\n{\"pattern\":\"FastVID\",}\n```"), + "{\"pattern\":\"FastVID\"}" + ); + } + + #[test] + fn repairs_json5_style_tool_arguments_like_ccr_enhancetool() { + let repaired: Value = serde_json::from_str(&repair_tool_arguments( + "{file_path:'main.tex', replace_all:false,}", + )) + .unwrap(); + + assert_eq!( + repaired, + json!({ "file_path": "main.tex", "replace_all": false }) + ); + } + + #[test] + fn repairs_commented_tool_arguments() { + let repaired: Value = serde_json::from_str(&repair_tool_arguments( + "{\n // target file\n file_path: 'main.tex',\n old_string: 'A',\n new_string: 'B',\n}", + )) + .unwrap(); + + assert_eq!( + repaired, + json!({ "file_path": "main.tex", "old_string": "A", "new_string": "B" }) + ); + } + + #[test] + fn repairs_mixed_quote_tool_arguments() { + let repaired: Value = serde_json::from_str(&repair_tool_arguments( + "{\"file_path\": 'main.tex', \"pattern\": 'FastVID'}", + )) + .unwrap(); + + assert_eq!( + repaired, + json!({ "file_path": "main.tex", "pattern": "FastVID" }) + ); + } + + #[test] + fn falls_back_to_empty_object_for_unrepairable_arguments() { + assert_eq!(repair_tool_arguments("not json at all"), "{}"); + } + + #[test] + fn normalizes_numeric_tool_call_ids() { + let id = normalized_tool_call_id(Some("123")); + + assert!(id.starts_with("call_")); + assert_ne!(id, "123"); + } + + #[test] + fn preserves_provider_tool_call_ids() { + assert_eq!(normalized_tool_call_id(Some("call_abc")), "call_abc"); + } +} diff --git a/apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs b/apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs new file mode 100644 index 0000000..ca4153e --- /dev/null +++ b/apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs @@ -0,0 +1,148 @@ +use super::OpenAiProxyCredential; + +const CLEANCACHE: &str = "cleancache"; +const DEEPSEEK: &str = "deepseek"; +const ENHANCETOOL: &str = "enhancetool"; +const STREAMOPTIONS: &str = "streamoptions"; +const TOOLUSE: &str = "tooluse"; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct ProxyTransformerChain { + names: Vec, +} + +impl ProxyTransformerChain { + pub(super) fn for_credential(credential: &OpenAiProxyCredential, wants_stream: bool) -> Self { + let mut chain = Self::default(); + chain.push(CLEANCACHE); + if wants_stream { + chain.push(STREAMOPTIONS); + } + if is_deepseek_credential(credential) { + chain.push(DEEPSEEK); + } + + // ClaudePrism already buffers and repairs tool-call arguments before + // returning them to Claude Code. Naming it here keeps the behavior + // traceable to Claude Code Router's enhancetool transformer. + chain.push(ENHANCETOOL); + for name in &credential.transformers { + chain.push(name); + } + for name in &credential.model_transformers { + chain.push(name); + } + for name in configured_transformer_names() { + chain.push(&name); + } + chain + } + + pub(super) fn has(&self, name: &str) -> bool { + self.names + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name)) + } + + pub(super) fn has_tooluse(&self) -> bool { + self.has(TOOLUSE) + } + + pub(super) fn has_cleancache(&self) -> bool { + self.has(CLEANCACHE) + } + + pub(super) fn has_deepseek(&self) -> bool { + self.has(DEEPSEEK) + } + + pub(super) fn has_streamoptions(&self) -> bool { + self.has(STREAMOPTIONS) + } + + #[cfg(test)] + pub(super) fn from_names(names: &[&str]) -> Self { + let mut chain = Self::default(); + for name in names { + chain.push(name); + } + chain + } + + fn push(&mut self, name: &str) { + let name = name.trim(); + if name.is_empty() || self.has(name) { + return; + } + self.names.push(name.to_ascii_lowercase()); + } +} + +fn configured_transformer_names() -> Vec { + std::env::var("CLAUDE_PRISM_PROXY_TRANSFORMERS") + .ok() + .into_iter() + .flat_map(|value| { + value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>() + }) + .collect() +} + +fn is_deepseek_credential(credential: &OpenAiProxyCredential) -> bool { + let base_url = credential.base_url.to_ascii_lowercase(); + let model = credential.model.to_ascii_lowercase(); + base_url.contains("deepseek") || model.contains("deepseek") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential { + OpenAiProxyCredential { + api_key: "sk-test".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + transformers: Vec::new(), + model_transformers: Vec::new(), + } + } + + #[test] + fn includes_ccr_style_defaults_for_common_provider_adapters() { + let chain = ProxyTransformerChain::for_credential( + &credential("https://api.deepseek.com", "deepseek-chat"), + true, + ); + + assert!(chain.has_cleancache()); + assert!(chain.has_streamoptions()); + assert!(chain.has_deepseek()); + assert!(chain.has(ENHANCETOOL)); + } + + #[test] + fn does_not_enable_tooluse_unless_configured() { + let chain = ProxyTransformerChain::for_credential( + &credential("https://api.example.com/v1", "qwen"), + false, + ); + + assert!(!chain.has_tooluse()); + } + + #[test] + fn accepts_explicit_model_transformers() { + let mut credential = credential("https://api.example.com/v1", "qwen"); + credential.model_transformers = vec!["tooluse".to_string()]; + + let chain = ProxyTransformerChain::for_credential(&credential, false); + + assert!(chain.has_tooluse()); + } +} diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index eb68bf2..42a5927 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -1,15 +1,86 @@ +use crate::anthropic_proxy::{start_openai_anthropic_proxy, OpenAiProxyCredential}; +pub use crate::claude_process::{kill_process_for_window, ClaudeProcessState}; +use crate::claude_process::{ + spawn_claude_process, stop_claude_process, ClaudeStopMode, SpawnProviderMetadata, +}; +use serde_json::{json, Value}; use std::borrow::Cow; -use std::collections::HashMap; -use std::path::PathBuf; +use std::collections::{HashMap, HashSet}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use tauri::{Emitter, Manager, WebviewWindow}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, Command}; +use tauri::{Emitter, WebviewWindow}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; use tokio::sync::Mutex; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +#[derive(Default, serde::Deserialize, serde::Serialize)] +#[serde(default)] +struct ClaudePrismAuthConfig { + provider: Option, + anthropic_api_key: Option, + anthropic_base_url: Option, + openai_api_key: Option, + openai_base_url: Option, + openai_model: Option, + active_openai_credential_id: Option, + openai_credentials: Vec, +} + +struct StoredClaudeCredential { + api_key: String, + base_url: Option, +} + +#[derive(Clone, Debug)] +struct StoredOpenAiCompatibleCredential { + id: String, + label: String, + api_key: String, + base_url: String, + model: String, + transformers: Vec, + model_transformers: HashMap>, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct StoredOpenAiCompatibleCredentialConfig { + id: String, + label: String, + api_key: String, + base_url: String, + model: String, + #[serde(default)] + transformers: Vec, + #[serde(default)] + model_transformers: HashMap>, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct OpenAiCompatibleCredentialInfo { + id: String, + label: String, + base_url: String, + model: String, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct OpenAiCompatibleModelInfo { + id: String, + metadata: Value, +} + +const PROVIDER_CLAUDE_CODE: &str = "claude-code"; +const PROVIDER_OPENAI_COMPATIBLE: &str = "openai-compatible"; +const MOONSHOT_OFFICIAL_ORIGIN: &str = "https://api.moonshot.ai"; + /// Check if an environment variable should be explicitly passed to child processes. /// -/// NOTE: This is NOT a true whitelist — we do NOT call `env_clear()`, so the +/// NOTE: This is NOT a true whitelist 鈥?we do NOT call `env_clear()`, so the /// child inherits the full parent environment. This helper only identifies vars /// that we *explicitly* re-set via `cmd.env()` to guarantee they are present /// even when other per-key overrides are applied (e.g. prepending to PATH). @@ -22,6 +93,8 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool { "HOME" | "USER" | "SHELL" | "LANG" | "HOMEBREW_PREFIX" | "HOMEBREW_CELLAR" | "HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY" | "ALL_PROXY" + | "ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN" + | "ANTHROPIC_BASE_URL" ) || k.starts_with("LC_") // Windows-specific || matches!( @@ -35,6 +108,1088 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool { ) } +fn normalize_proxy_url_with_default(raw: &str, default_scheme: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.contains("://") { + Some(trimmed.to_string()) + } else { + Some(format!("{}://{}", default_scheme, trimmed)) + } +} + +fn normalize_proxy_url(raw: &str) -> Option { + normalize_proxy_url_with_default(raw, "http") +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProxyEnvVar { + key: &'static str, + value: String, + source: String, +} + +fn first_env_value(names: &[&str]) -> Option<(String, String)> { + for name in names { + let Ok(value) = std::env::var(name) else { + continue; + }; + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(((*name).to_string(), trimmed.to_string())); + } + } + + None +} + +fn explicit_proxy_env_vars() -> Vec { + let mut vars = Vec::new(); + let mut has_https_proxy = false; + let mut has_all_proxy = false; + let mut http_proxy = None; + + if let Some((source, raw)) = first_env_value(&["HTTPS_PROXY", "https_proxy"]) { + if let Some(value) = normalize_proxy_url(&raw) { + vars.push(ProxyEnvVar { + key: "HTTPS_PROXY", + value, + source, + }); + has_https_proxy = true; + } + } + + if let Some((source, raw)) = first_env_value(&["HTTP_PROXY", "http_proxy"]) { + if let Some(value) = normalize_proxy_url(&raw) { + http_proxy = Some((source.clone(), value.clone())); + vars.push(ProxyEnvVar { + key: "HTTP_PROXY", + value, + source, + }); + } + } + + if let Some((source, raw)) = first_env_value(&["ALL_PROXY", "all_proxy"]) { + if let Some(value) = normalize_proxy_url(&raw) { + vars.push(ProxyEnvVar { + key: "ALL_PROXY", + value, + source, + }); + has_all_proxy = true; + } + } + + if !has_https_proxy && !has_all_proxy { + if let Some((source, value)) = http_proxy { + vars.insert( + 0, + ProxyEnvVar { + key: "HTTPS_PROXY", + value, + source: format!("{} (HTTPS fallback)", source), + }, + ); + } + } + + vars +} + +#[cfg(target_os = "windows")] +fn windows_proxy_override_to_no_proxy_env(raw: &str) -> Option { + let entries = raw + .split([';', ',']) + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.eq_ignore_ascii_case("") { + return Some("localhost,127.0.0.1,::1".to_string()); + } + + if trimmed == "*" { + return Some(trimmed.to_string()); + } + + if trimmed.contains('*') { + return trimmed + .strip_prefix("*.") + .map(|domain| format!(".{}", domain.trim_start_matches('.'))); + } + + Some(trimmed.to_string()) + }) + .collect::>(); + + if entries.is_empty() { + None + } else { + Some(entries.join(",")) + } +} + +#[cfg(target_os = "windows")] +fn windows_system_no_proxy_env() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let settings = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + .ok()?; + let raw = settings.get_value::("ProxyOverride").ok()?; + windows_proxy_override_to_no_proxy_env(&raw) +} + +#[cfg(target_os = "windows")] +fn parse_windows_proxy_server_for_env(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + if !trimmed.contains('=') { + let Some(value) = normalize_proxy_url(trimmed) else { + return Vec::new(); + }; + return vec![ + ProxyEnvVar { + key: "HTTPS_PROXY", + value: value.clone(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "HTTP_PROXY", + value: value.clone(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "ALL_PROXY", + value, + source: "Windows system proxy".to_string(), + }, + ]; + } + + let mut vars = Vec::new(); + let mut has_https_proxy = false; + let mut has_all_proxy = false; + let mut http_proxy = None; + + for entry in trimmed.split(';') { + let Some((scheme, value)) = entry.split_once('=') else { + continue; + }; + let scheme = scheme.trim(); + let (key, default_proxy_scheme) = match scheme.to_ascii_lowercase().as_str() { + "http" => ("HTTP_PROXY", "http"), + "https" => ("HTTPS_PROXY", "http"), + "socks" | "socks5" => ("ALL_PROXY", "socks5"), + "socks4" => ("ALL_PROXY", "socks4"), + _ => continue, + }; + let Some(value) = normalize_proxy_url_with_default(value, default_proxy_scheme) else { + continue; + }; + let source = format!("Windows system proxy ({})", scheme); + + if key == "HTTP_PROXY" { + http_proxy = Some((source.clone(), value.clone())); + } else if key == "HTTPS_PROXY" { + has_https_proxy = true; + } else if key == "ALL_PROXY" { + has_all_proxy = true; + } + + vars.push(ProxyEnvVar { key, value, source }); + } + + if !has_https_proxy && !has_all_proxy { + if let Some((source, value)) = http_proxy { + vars.insert( + 0, + ProxyEnvVar { + key: "HTTPS_PROXY", + value, + source: format!("{} (HTTPS fallback)", source), + }, + ); + } + } + + vars +} + +#[cfg(target_os = "windows")] +fn windows_system_proxy_env_vars() -> Vec { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + else { + return Vec::new(); + }; + + let proxy_enabled = settings.get_value::("ProxyEnable").unwrap_or(0) != 0; + if !proxy_enabled { + return Vec::new(); + } + + settings + .get_value::("ProxyServer") + .map(|raw| parse_windows_proxy_server_for_env(&raw)) + .unwrap_or_default() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_proxy_env_vars() -> Vec { + Vec::new() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_no_proxy_env() -> Option { + None +} + +fn redacted_proxy_url(url: &str) -> String { + let Ok(mut parsed) = reqwest::Url::parse(url) else { + return "".to_string(); + }; + + if !parsed.username().is_empty() { + let _ = parsed.set_username("***"); + if parsed.password().is_some() { + let _ = parsed.set_password(Some("***")); + } + } + + parsed.to_string() +} + +pub(crate) fn apply_proxy_env_to_command(cmd: &mut Command, window: Option<&WebviewWindow>) { + let mut vars = explicit_proxy_env_vars(); + let mut no_proxy = first_env_value(&["NO_PROXY", "no_proxy"]).map(|(_, value)| value); + + if vars.is_empty() { + vars = windows_system_proxy_env_vars(); + if no_proxy.is_none() { + no_proxy = windows_system_no_proxy_env(); + } + } + + if vars.is_empty() { + if let Some(window) = window { + let _ = window.emit( + "install-output", + "Using system proxy settings when available", + ); + } + return; + } + + for var in vars { + if let Some(window) = window { + let _ = window.emit( + "install-output", + format!( + "Using proxy from {}: {}", + var.source, + redacted_proxy_url(&var.value) + ), + ); + } + cmd.env(var.key, var.value); + } + + if let Some(no_proxy) = no_proxy { + cmd.env("NO_PROXY", no_proxy); + } +} + +fn get_claude_prism_auth_path() -> Result { + let config_dir = dirs::config_dir() + .or_else(dirs::home_dir) + .ok_or("Could not find config directory")?; + Ok(config_dir.join("ClaudePrism").join("anthropic-auth.json")) +} + +fn read_claude_prism_auth_config() -> Result { + let path = get_claude_prism_auth_path()?; + if !path.exists() { + return Ok(ClaudePrismAuthConfig::default()); + } + + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read auth settings: {}", e))?; + let content = content.trim_start_matches('\u{feff}'); + let config = serde_json::from_str(content) + .map_err(|e| format!("Failed to parse auth settings: {}", e))?; + restrict_auth_file_permissions(&path)?; + Ok(config) +} + +fn restrict_auth_file_permissions(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to lock down auth settings permissions: {}", e))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +fn backup_corrupt_auth_config(path: &Path, reason: &str) -> Result { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "anthropic-auth.json".to_string()); + let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S"); + let backup = path.with_file_name(format!("{}.corrupt-{}.bak", file_name, timestamp)); + std::fs::rename(path, &backup) + .map_err(|e| format!("Failed to back up corrupt auth settings: {}", e))?; + let _ = restrict_auth_file_permissions(&backup); + eprintln!( + "[auth] backed up corrupt auth settings to {}: {}", + backup.display(), + reason + ); + Ok(backup) +} + +fn read_claude_prism_auth_config_for_update() -> Result { + match read_claude_prism_auth_config() { + Ok(config) => Ok(config), + Err(err) => { + let path = get_claude_prism_auth_path()?; + if path.exists() { + backup_corrupt_auth_config(&path, &err)?; + Ok(ClaudePrismAuthConfig::default()) + } else { + Err(err) + } + } + } +} + +fn write_claude_prism_auth_config(config: &ClaudePrismAuthConfig) -> Result<(), String> { + let path = get_claude_prism_auth_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create auth settings dir: {}", e))?; + } + + let content = serde_json::to_string_pretty(config) + .map_err(|e| format!("Failed to serialize auth settings: {}", e))?; + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options + .open(&path) + .map_err(|e| format!("Failed to write auth settings: {}", e))?; + file.write_all(content.as_bytes()) + .map_err(|e| format!("Failed to write auth settings: {}", e))?; + file.flush() + .map_err(|e| format!("Failed to flush auth settings: {}", e))?; + restrict_auth_file_permissions(&path) +} + +fn normalize_api_key(value: &str) -> Result { + let clean = strip_nul(value).trim().to_string(); + if clean.is_empty() { + return Err("API key is empty".to_string()); + } + + if clean.chars().any(char::is_whitespace) { + return Err("API key cannot contain spaces or line breaks".to_string()); + } + + Ok(clean) +} + +fn normalize_optional_api_key(value: &str) -> Result { + let clean = strip_nul(value).trim().to_string(); + if clean.chars().any(char::is_whitespace) { + return Err("API key cannot contain spaces or line breaks".to_string()); + } + + Ok(clean) +} + +fn normalize_base_url(value: Option<&str>) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + + let clean = strip_nul(value).trim().trim_end_matches('/').to_string(); + if clean.is_empty() { + return Ok(None); + } + + if clean.chars().any(char::is_whitespace) { + return Err("Base URL cannot contain spaces or line breaks".to_string()); + } + + if !(clean.starts_with("https://") || clean.starts_with("http://")) { + return Err("Base URL must start with http:// or https://".to_string()); + } + + Ok(Some(clean)) +} + +fn ensure_secure_known_provider_base_url(base_url: &str) -> Result<(), String> { + let lower = base_url.to_ascii_lowercase(); + let insecure_known_provider = [ + "http://api.deepseek.com", + "http://dashscope.aliyuncs.com", + "http://dashscope-intl.aliyuncs.com", + "http://api.moonshot.cn", + "http://api.moonshot.ai", + ] + .iter() + .any(|prefix| lower == *prefix || lower.starts_with(&format!("{}/", prefix))); + + if insecure_known_provider { + return Err( + "Known cloud provider endpoints must use https:// to avoid sending API keys in plaintext." + .to_string(), + ); + } + + Ok(()) +} + +fn normalize_provider(value: Option<&str>) -> Result { + let provider = value.unwrap_or(PROVIDER_CLAUDE_CODE).trim(); + match provider { + "" | PROVIDER_CLAUDE_CODE => Ok(PROVIDER_CLAUDE_CODE.to_string()), + PROVIDER_OPENAI_COMPATIBLE => Ok(PROVIDER_OPENAI_COMPATIBLE.to_string()), + other => Err(format!("Unsupported provider: {}", other)), + } +} + +fn normalize_model(value: Option<&str>) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + + let clean = strip_nul(value).trim().to_string(); + if clean.is_empty() { + return Ok(None); + } + + if clean.chars().any(char::is_whitespace) { + return Err("Model cannot contain spaces or line breaks".to_string()); + } + + Ok(Some(clean)) +} + +fn normalized_transformer_names(values: &[String]) -> Vec { + let mut seen = HashSet::new(); + values + .iter() + .map(|value| strip_nul(value).trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .filter(|value| seen.insert(value.clone())) + .collect() +} + +fn normalized_model_transformers( + values: &HashMap>, +) -> HashMap> { + values + .iter() + .filter_map(|(model, transformers)| { + let model = strip_nul(model).trim().to_string(); + if model.is_empty() { + return None; + } + let transformers = normalized_transformer_names(transformers); + if transformers.is_empty() { + None + } else { + Some((model, transformers)) + } + }) + .collect() +} + +fn is_claude_model_selector(value: &str) -> bool { + let model = value.trim().to_ascii_lowercase(); + if model.starts_with("claude") { + return true; + } + + matches!(model.as_str(), "sonnet" | "opus" | "haiku" | "opusplan") +} + +fn normalize_provider_model_override(value: Option<&str>) -> Result, String> { + let Some(model) = normalize_model(value)? else { + return Ok(None); + }; + + if is_claude_model_selector(&model) { + return Ok(None); + } + + Ok(Some(model)) +} + +fn known_proxy_mismatch_error(provider: &str, base_url: Option<&str>) -> Option { + let lower = base_url?.to_ascii_lowercase(); + if lower.contains("/codex-proxy") { + return Some( + "ModelGate codex-proxy uses the OpenAI Responses API, not chat/completions or Claude Code. Use a chat/completions-compatible endpoint for OpenAI-compatible providers, or choose the ModelGate Claude proxy preset for Claude Code." + .to_string(), + ); + } + + if provider == PROVIDER_OPENAI_COMPATIBLE && lower.contains("/claude-proxy") { + return Some( + "This is a Claude-compatible proxy endpoint. Select Claude Code / Anthropic API and the ModelGate Claude preset instead of OpenAI-compatible API." + .to_string(), + ); + } + + None +} + +fn stored_claude_credential() -> Option { + let config = read_claude_prism_auth_config().ok()?; + stored_claude_credential_from_config(&config) +} + +fn stored_claude_credential_from_config( + config: &ClaudePrismAuthConfig, +) -> Option { + let api_key = config + .anthropic_api_key + .as_deref() + .and_then(|value| normalize_api_key(value).ok())?; + let base_url = normalize_base_url(config.anthropic_base_url.as_deref()).ok()?; + + if base_url.is_none() && !api_key.starts_with("sk-ant-") { + return None; + } + + Some(StoredClaudeCredential { api_key, base_url }) +} + +fn stored_openai_compatible_credential_by_id( + credential_id: Option<&str>, +) -> Result, String> { + let config = read_claude_prism_auth_config()?; + openai_compatible_credential_by_id_from_config(&config, credential_id) +} + +fn normalized_openai_compatible_credentials( + config: &ClaudePrismAuthConfig, +) -> Vec { + let mut credentials = Vec::new(); + for credential in &config.openai_credentials { + let Ok(api_key) = normalize_optional_api_key(&credential.api_key) else { + continue; + }; + let Some(base_url) = normalize_base_url(Some(credential.base_url.as_str())) + .ok() + .flatten() + else { + continue; + }; + let Some(model) = normalize_model(Some(credential.model.as_str())) + .ok() + .flatten() + else { + continue; + }; + let id = strip_nul(&credential.id).trim().to_string(); + if id.is_empty() { + continue; + } + let label = strip_nul(&credential.label).trim().to_string(); + credentials.push(StoredOpenAiCompatibleCredential { + id, + label: if label.is_empty() { + model.clone() + } else { + label + }, + api_key, + base_url, + model, + transformers: normalized_transformer_names(&credential.transformers), + model_transformers: normalized_model_transformers(&credential.model_transformers), + }); + } + + if credentials.is_empty() { + if let (Some(api_key), Some(base_url), Some(model)) = ( + config + .openai_api_key + .as_deref() + .and_then(|value| normalize_optional_api_key(value).ok()), + normalize_base_url(config.openai_base_url.as_deref()) + .ok() + .flatten(), + normalize_model(config.openai_model.as_deref()) + .ok() + .flatten(), + ) { + credentials.push(StoredOpenAiCompatibleCredential { + id: "legacy-openai-compatible".to_string(), + label: model.clone(), + api_key, + base_url, + model, + transformers: Vec::new(), + model_transformers: HashMap::new(), + }); + } + } + + credentials +} + +fn stored_openai_compatible_credential_from_config( + config: &ClaudePrismAuthConfig, + credential_id: Option<&str>, +) -> Option { + let credentials = normalized_openai_compatible_credentials(config); + if let Some(credential_id) = credential_id { + if let Some(credential) = credentials + .iter() + .find(|credential| credential.id == credential_id) + .cloned() + { + return Some(credential); + } + } + + let provider = normalize_provider(config.provider.as_deref()).ok()?; + if provider != PROVIDER_OPENAI_COMPATIBLE { + return None; + } + + if let Some(active_id) = config.active_openai_credential_id.as_deref() { + if let Some(credential) = credentials + .iter() + .find(|credential| credential.id == active_id) + .cloned() + { + return Some(credential); + } + } + + credentials.into_iter().next() +} + +fn openai_compatible_credential_by_id_from_config( + config: &ClaudePrismAuthConfig, + credential_id: Option<&str>, +) -> Result, String> { + let credential_id = credential_id + .map(strip_nul) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let Some(credential_id) = credential_id else { + return Ok(None); + }; + + normalized_openai_compatible_credentials(config) + .into_iter() + .find(|credential| credential.id == credential_id) + .map(Some) + .ok_or_else(|| "Configured provider credential not found".to_string()) +} + +fn claude_credential_label() -> Option<&'static str> { + if std::env::var("ANTHROPIC_API_KEY") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + if std::env::var("ANTHROPIC_BASE_URL") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Some("External API key"); + } + return Some("Anthropic API key"); + } + + if std::env::var("ANTHROPIC_AUTH_TOKEN") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return Some("Anthropic auth token"); + } + + if let Some(credential) = stored_claude_credential() { + return Some(if credential.base_url.is_some() { + "External API key" + } else { + "Anthropic API key" + }); + } + + None +} + +fn claude_credential_env_values( + credential: &StoredClaudeCredential, +) -> Vec<(&'static str, String)> { + let mut values = vec![("ANTHROPIC_API_KEY", credential.api_key.clone())]; + if let Some(base_url) = &credential.base_url { + values.push(("ANTHROPIC_BASE_URL", base_url.clone())); + values.push(("ANTHROPIC_AUTH_TOKEN", credential.api_key.clone())); + } + values +} + +#[tauri::command] +pub async fn save_anthropic_api_key( + api_key: String, + base_url: Option, + provider: Option, + model: Option, + credential_label: Option, + credential_id: Option, +) -> Result<(), String> { + let provider = normalize_provider(provider.as_deref())?; + let api_key = if provider == PROVIDER_OPENAI_COMPATIBLE { + normalize_optional_api_key(&api_key)? + } else { + normalize_api_key(&api_key)? + }; + let base_url = normalize_base_url(base_url.as_deref())?; + let model = normalize_model(model.as_deref())?; + if let Some(message) = known_proxy_mismatch_error(&provider, base_url.as_deref()) { + return Err(message); + } + + // Saving a new key should repair an empty/corrupt legacy auth file after + // backing it up, never silently discard parseable credentials. + let mut config = read_claude_prism_auth_config_for_update()?; + config.provider = Some(provider.clone()); + + if provider == PROVIDER_OPENAI_COMPATIBLE { + let base_url = base_url.ok_or("OpenAI-compatible provider requires a Base URL")?; + ensure_secure_known_provider_base_url(&base_url)?; + let model = model.ok_or("OpenAI-compatible provider requires a model")?; + let label = credential_label + .as_deref() + .map(strip_nul) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| model.clone()); + let credential_id = credential_id + .as_deref() + .map(strip_nul) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + config + .openai_credentials + .iter() + .find(|credential| { + credential.label == label + && credential.base_url == base_url + && credential.model == model + }) + .map(|credential| credential.id.clone()) + }) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let (transformers, model_transformers) = config + .openai_credentials + .iter() + .find(|item| item.id == credential_id) + .map(|item| { + ( + normalized_transformer_names(&item.transformers), + normalized_model_transformers(&item.model_transformers), + ) + }) + .unwrap_or_else(|| (Vec::new(), HashMap::new())); + + let credential = StoredOpenAiCompatibleCredentialConfig { + id: credential_id.clone(), + label, + api_key: api_key.clone(), + base_url: base_url.clone(), + model: model.clone(), + transformers, + model_transformers, + }; + + if let Some(existing) = config + .openai_credentials + .iter_mut() + .find(|item| item.id == credential_id) + { + *existing = credential; + } else { + config.openai_credentials.push(credential); + } + + config.active_openai_credential_id = Some(credential_id); + config.openai_api_key = Some(api_key); + config.openai_base_url = Some(base_url); + config.openai_model = Some(model); + return write_claude_prism_auth_config(&config); + } + + if base_url.is_none() && !api_key.starts_with("sk-ant-") { + return Err( + "This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-." + .to_string(), + ); + } + + config.anthropic_api_key = Some(api_key); + config.anthropic_base_url = base_url; + write_claude_prism_auth_config(&config) +} + +#[tauri::command] +pub async fn verify_openai_compatible_api_key( + api_key: String, + base_url: String, + model: String, +) -> Result<(), String> { + let api_key = normalize_optional_api_key(&api_key)?; + let base_url = normalize_base_url(Some(base_url.as_str()))? + .ok_or("OpenAI-compatible provider requires a Base URL")?; + ensure_secure_known_provider_base_url(&base_url)?; + if let Some(message) = + known_proxy_mismatch_error(PROVIDER_OPENAI_COMPATIBLE, Some(base_url.as_str())) + { + return Err(message); + } + let model = normalize_model(Some(model.as_str()))? + .ok_or("OpenAI-compatible provider requires a model")?; + let credential = StoredOpenAiCompatibleCredential { + id: "verification".to_string(), + label: model.clone(), + api_key, + base_url, + model, + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + verify_openai_compatible_credential(&credential).await +} + +#[tauri::command] +pub async fn list_openai_compatible_models( + api_key: String, + base_url: String, +) -> Result, String> { + let api_key = normalize_optional_api_key(&api_key)?; + let base_url = normalize_base_url(Some(base_url.as_str()))? + .ok_or("OpenAI-compatible provider requires a Base URL")?; + ensure_secure_known_provider_base_url(&base_url)?; + if let Some(message) = + known_proxy_mismatch_error(PROVIDER_OPENAI_COMPATIBLE, Some(base_url.as_str())) + { + return Err(message); + } + + fetch_openai_compatible_models(&api_key, &base_url).await +} + +#[tauri::command] +pub async fn list_openai_compatible_credential_models( + credential_id: String, +) -> Result, String> { + let config = read_claude_prism_auth_config()?; + let credential = normalized_openai_compatible_credentials(&config) + .into_iter() + .find(|credential| credential.id == credential_id) + .ok_or("Configured provider credential not found")?; + + fetch_openai_compatible_models(&credential.api_key, &credential.base_url).await +} + +async fn fetch_openai_compatible_models( + api_key: &str, + base_url: &str, +) -> Result, String> { + ensure_secure_known_provider_base_url(base_url)?; + let request = reqwest::Client::new().get(openai_models_url(base_url)); + let response = with_optional_bearer_auth(request, api_key) + .send() + .await + .map_err(|err| format!("Failed to fetch provider models: {}", err))?; + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider models response: {}", err))?; + + if !status.is_success() { + return Err(openai_compatible_verification_error(status, &response_text)); + } + + let value: Value = serde_json::from_str(&response_text) + .map_err(|err| format!("Provider returned invalid models JSON: {}", err))?; + let mut models = value + .get("data") + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| { + item.get("id") + .and_then(|value| value.as_str()) + .map(|id| OpenAiCompatibleModelInfo { + id: id.to_string(), + metadata: item.clone(), + }) + .or_else(|| { + item.as_str().map(|id| OpenAiCompatibleModelInfo { + id: id.to_string(), + metadata: json!({ "id": id }), + }) + }) + }) + .collect::>() + }) + .unwrap_or_default(); + let mut seen = HashSet::new(); + models.retain(|model| seen.insert(model.id.clone())); + if models.is_empty() { + return Err("Provider did not return any models.".to_string()); + } + + Ok(models) +} + +#[tauri::command] +pub async fn clear_anthropic_api_key() -> Result<(), String> { + // Clearing should also recover from an empty/corrupt legacy auth file after + // preserving the bad file for manual recovery. + let mut config = read_claude_prism_auth_config_for_update()?; + config.provider = Some(PROVIDER_CLAUDE_CODE.to_string()); + config.anthropic_api_key = None; + config.anthropic_base_url = None; + config.openai_api_key = None; + config.openai_base_url = None; + config.openai_model = None; + config.active_openai_credential_id = None; + config.openai_credentials.clear(); + write_claude_prism_auth_config(&config) +} + +#[tauri::command] +pub async fn list_openai_compatible_credentials( +) -> Result, String> { + let config = read_claude_prism_auth_config()?; + Ok(normalized_openai_compatible_credentials(&config) + .into_iter() + .map(|credential| OpenAiCompatibleCredentialInfo { + id: credential.id, + label: credential.label, + base_url: credential.base_url, + model: credential.model, + }) + .collect()) +} + +#[tauri::command] +pub async fn delete_openai_compatible_credential(credential_id: String) -> Result<(), String> { + let credential_id = strip_nul(&credential_id).trim().to_string(); + if credential_id.is_empty() { + return Err("Provider credential id is empty".to_string()); + } + + let mut config = read_claude_prism_auth_config_for_update()?; + if credential_id == "legacy-openai-compatible" && config.openai_credentials.is_empty() { + if config.openai_api_key.is_none() + && config.openai_base_url.is_none() + && config.openai_model.is_none() + { + return Err("Configured provider credential not found".to_string()); + } + config.openai_api_key = None; + config.openai_base_url = None; + config.openai_model = None; + config.active_openai_credential_id = None; + config.provider = Some(PROVIDER_CLAUDE_CODE.to_string()); + return write_claude_prism_auth_config(&config); + } + + let before_len = config.openai_credentials.len(); + config + .openai_credentials + .retain(|credential| credential.id != credential_id); + if config.openai_credentials.len() == before_len { + return Err("Configured provider credential not found".to_string()); + } + + let current_provider_is_openai = normalize_provider(config.provider.as_deref()) + .map(|provider| provider == PROVIDER_OPENAI_COMPATIBLE) + .unwrap_or(false); + let active_id = config.active_openai_credential_id.as_deref(); + let active_was_deleted = active_id == Some(&credential_id); + let active_is_missing = current_provider_is_openai + && active_id + .map(|id| { + normalized_openai_compatible_credentials(&config) + .iter() + .all(|credential| credential.id != id) + }) + .unwrap_or(true); + let deleted_active = active_was_deleted || active_is_missing; + + if deleted_active { + if let Some(next) = config.openai_credentials.first() { + config.provider = Some(PROVIDER_OPENAI_COMPATIBLE.to_string()); + config.active_openai_credential_id = Some(next.id.clone()); + config.openai_api_key = Some(next.api_key.clone()); + config.openai_base_url = Some(next.base_url.clone()); + config.openai_model = Some(next.model.clone()); + } else { + config.provider = Some(PROVIDER_CLAUDE_CODE.to_string()); + config.active_openai_credential_id = None; + config.openai_api_key = None; + config.openai_base_url = None; + config.openai_model = None; + } + } + + write_claude_prism_auth_config(&config) +} + +#[tauri::command] +pub async fn set_active_openai_compatible_credential(credential_id: String) -> Result<(), String> { + let mut config = read_claude_prism_auth_config_for_update()?; + let credential = normalized_openai_compatible_credentials(&config) + .into_iter() + .find(|credential| credential.id == credential_id) + .ok_or("Configured provider credential not found")?; + + config.provider = Some(PROVIDER_OPENAI_COMPATIBLE.to_string()); + config.active_openai_credential_id = Some(credential.id); + config.openai_api_key = Some(credential.api_key); + config.openai_base_url = Some(credential.base_url); + config.openai_model = Some(credential.model); + write_claude_prism_auth_config(&config) +} + /// Windows CREATE_NO_WINDOW flag to prevent console windows from flashing /// when spawning child processes (e.g. Claude CLI, cmd.exe, node.exe). #[cfg(target_os = "windows")] @@ -43,19 +1198,6 @@ const CREATE_NO_WINDOW: u32 = 0x08000000; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; -#[derive(Clone)] -pub struct ClaudeProcessState { - pub processes: Arc>>, -} - -impl Default for ClaudeProcessState { - fn default() -> Self { - Self { - processes: Arc::new(Mutex::new(HashMap::new())), - } - } -} - /// On Windows, read User + System PATH from the registry and search for claude. /// This catches cases where claude was installed after the GUI app launched, /// since the process PATH is stale but the registry PATH is up to date. @@ -113,7 +1255,10 @@ fn expand_env_vars(s: &str) -> String { use std::ffi::OsString; use std::os::windows::ffi::{OsStrExt, OsStringExt}; - let wide: Vec = OsString::from(s).encode_wide().chain(std::iter::once(0)).collect(); + let wide: Vec = OsString::from(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); // First call to get required buffer size let size = unsafe { @@ -147,8 +1292,7 @@ fn expand_env_vars(s: &str) -> String { } /// Discover the claude binary on the system. -/// Search order: ~/.local/bin → NVM_BIN → which → registry PATH (Windows) → -/// login shell (Unix) → npm/nvm global → standard paths → user-specific paths. +/// Search order: ~/.local/bin 鈫?NVM_BIN 鈫?which 鈫?registry PATH (Windows) 鈫?/// login shell (Unix) 鈫?npm/nvm global 鈫?standard paths 鈫?user-specific paths. /// Returns Err if not found. fn find_claude_binary() -> Result { // 1. Check the native installer's default location first @@ -319,9 +1463,7 @@ fn find_claude_binary() -> Result { .join("pnpm") .join("claude.cmd"), // Scoop - home.join("scoop") - .join("shims") - .join("claude.cmd"), + home.join("scoop").join("shims").join("claude.cmd"), // Standard Node.js install PathBuf::from(r"C:\Program Files\nodejs\claude.cmd"), ]; @@ -337,14 +1479,20 @@ fn find_claude_binary() -> Result { } #[cfg(any(test, not(target_os = "windows")))] -fn unix_claude_candidate_paths(home: &std::path::Path, pnpm_home: Option) -> Vec { +fn unix_claude_candidate_paths( + home: &std::path::Path, + pnpm_home: Option, +) -> Vec { let mut paths = Vec::new(); if let Some(pnpm_home) = pnpm_home.filter(|value| !value.is_empty()) { paths.push(PathBuf::from(pnpm_home).join("claude")); } paths.extend([ home.join("Library").join("pnpm").join("claude"), - home.join(".local").join("share").join("pnpm").join("claude"), + home.join(".local") + .join("share") + .join("pnpm") + .join("claude"), home.join(".pnpm").join("claude"), home.join(".claude").join("local").join("claude"), home.join(".npm-global").join("bin").join("claude"), @@ -411,9 +1559,9 @@ fn unix_shell_manager_candidate_paths(home: &std::path::Path) -> Vec { paths.push(unix_claude_path_from_npm_prefix(npm_prefix)); } - if let Some(yarn_bin) = run_login_shell_command( - "command -v yarn >/dev/null 2>&1 && yarn global bin 2>/dev/null", - ) { + if let Some(yarn_bin) = + run_login_shell_command("command -v yarn >/dev/null 2>&1 && yarn global bin 2>/dev/null") + { paths.push(unix_claude_path_from_bin_dir(yarn_bin)); } @@ -426,16 +1574,31 @@ fn unix_shell_manager_candidate_paths(home: &std::path::Path) -> Vec { fn unix_known_pnpm_claude_paths(home: &std::path::Path) -> Vec { vec![ home.join("Library").join("pnpm").join("claude"), - home.join("Library").join("pnpm").join("global").join("bin").join("claude"), - home.join(".local").join("share").join("pnpm").join("claude"), - home.join(".local").join("share").join("pnpm").join("global").join("bin").join("claude"), + home.join("Library") + .join("pnpm") + .join("global") + .join("bin") + .join("claude"), + home.join(".local") + .join("share") + .join("pnpm") + .join("claude"), + home.join(".local") + .join("share") + .join("pnpm") + .join("global") + .join("bin") + .join("claude"), home.join(".pnpm").join("claude"), home.join(".pnpm").join("global").join("bin").join("claude"), ] } #[cfg(any(test, not(target_os = "windows")))] -fn unix_extra_tool_dirs(home: &std::path::Path, pnpm_home: Option) -> Vec { +fn unix_extra_tool_dirs( + home: &std::path::Path, + pnpm_home: Option, +) -> Vec { let mut dirs = vec![ home.join(".local").join("bin"), home.join(".cargo").join("bin"), @@ -443,7 +1606,11 @@ fn unix_extra_tool_dirs(home: &std::path::Path, pnpm_home: Option (String, Vec) { .join("claude-code") .join("cli.js"); if cli_js.exists() { - // Find node.exe — prefer one next to the .cmd, then fall back to PATH + // Find node.exe 鈥?prefer one next to the .cmd, then fall back to PATH let node = { let local_node = cmd_dir.join("node.exe"); if local_node.exists() { @@ -630,7 +1797,6 @@ fn resolve_cmd_to_node(program: &str) -> (String, Vec) { fn new_sync_command(program: &str) -> std::process::Command { #[cfg(target_os = "windows")] { - let (resolved, prefix) = resolve_cmd_to_node(program); let mut c = std::process::Command::new(&resolved); c.creation_flags(CREATE_NO_WINDOW); @@ -656,7 +1822,6 @@ fn create_command( #[cfg(target_os = "windows")] let mut cmd = { - let (resolved, prefix) = resolve_cmd_to_node(clean_program.as_ref()); let mut c = Command::new(&resolved); c.creation_flags(CREATE_NO_WINDOW); @@ -687,7 +1852,7 @@ fn create_command( cmd.env_remove("CLAUDECODE"); cmd.env_remove("CLAUDE_AGENT_SDK_VERSION"); for (key, _) in std::env::vars() { - // Keep CLAUDE_CODE_GIT_BASH_PATH — Claude Code needs it on Windows to locate git-bash + // Keep CLAUDE_CODE_GIT_BASH_PATH 鈥?Claude Code needs it on Windows to locate git-bash if key == "CLAUDE_CODE_GIT_BASH_PATH" { continue; } @@ -698,6 +1863,17 @@ fn create_command( // Set effort level (default: low for fast responses) cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low")); + if let Some(credential) = stored_claude_credential() { + for (key, value) in claude_credential_env_values(&credential) { + if std::env::var(key) + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + cmd.env(key, value); + } + } + } + // On Windows, ensure CLAUDE_CODE_GIT_BASH_PATH is set. // Claude Code requires git-bash to run on Windows. // Uses find_git_bash() which also validates user-specified paths. @@ -754,8 +1930,7 @@ fn create_command( if let Some(nvm_bin_path) = candidates.first() { let nvm_bin_str = nvm_bin_path.to_string_lossy(); if !current_path.contains(nvm_bin_str.as_ref()) { - current_path = - format!("{}{}{}", nvm_bin_str, sep, current_path); + current_path = format!("{}{}{}", nvm_bin_str, sep, current_path); } } } @@ -773,6 +1948,9 @@ fn create_command( let venv_dir = std::path::Path::new(cwd).join(".venv"); if venv_dir.exists() { cmd.env("VIRTUAL_ENV", &venv_dir); + cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir); + cmd.env("PYTHONNOUSERSITE", "1"); + cmd.env("PIP_REQUIRE_VIRTUALENV", "true"); #[cfg(not(target_os = "windows"))] let venv_bin = venv_dir.join("bin"); #[cfg(target_os = "windows")] @@ -785,6 +1963,29 @@ fn create_command( cmd } +fn clear_anthropic_provider_env(cmd: &mut Command) { + for key in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_CUSTOM_HEADERS", + "ANTHROPIC_BETA", + "ANTHROPIC_VERSION", + ] { + cmd.env_remove(key); + } + for (key, _) in std::env::vars() { + if key.to_ascii_uppercase().starts_with("ANTHROPIC_") { + cmd.env_remove(key); + } + } +} + fn with_prompt_transport(mut args: Vec, prompt: String) -> (Vec, Option) { args.push("-p".to_string()); #[cfg(target_os = "windows")] @@ -798,226 +1999,7 @@ fn with_prompt_transport(mut args: Vec, prompt: String) -> (Vec, } } -// ─── Event payloads (include tab_id for multi-tab routing) ─── - -#[derive(Clone, serde::Serialize)] -struct ClaudeOutputEvent { - tab_id: String, - data: String, -} - -#[derive(Clone, serde::Serialize)] -struct ClaudeCompleteEvent { - tab_id: String, - success: bool, -} - -#[derive(Clone, serde::Serialize)] -struct ClaudeErrorEvent { - tab_id: String, - data: String, -} - -/// Spawn the Claude CLI process and stream output via Tauri events. -/// Events are emitted only to the originating window, tagged with tab_id. -async fn spawn_claude_process( - window: WebviewWindow, - mut cmd: Command, - tab_id: String, - stdin_payload: Option, -) -> Result<(), String> { - let window_label = window.label().to_string(); - let process_key = format!("{}:{}", window_label, tab_id); - - if stdin_payload.is_some() { - cmd.stdin(std::process::Stdio::piped()); - } - - // Spawn the process - let mut child = cmd.spawn().map_err(|e| { - eprintln!( - "[claude-spawn] Failed to spawn process for tab {}: {}", - tab_id, e - ); - format!( - "Failed to spawn Claude process: {}. Is Claude Code CLI installed?", - e - ) - })?; - - if let Some(payload) = stdin_payload { - let mut stdin = child - .stdin - .take() - .ok_or_else(|| "Failed to acquire stdin for Claude process".to_string())?; - stdin - .write_all(payload.as_bytes()) - .await - .map_err(|e| format!("Failed to write prompt to Claude process stdin: {}", e))?; - stdin - .shutdown() - .await - .map_err(|e| format!("Failed to close Claude process stdin: {}", e))?; - } - - let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; - let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; - - // Get a clone of the process state Arc before any moves - let process_arc = window - .state::() - .inner() - .processes - .clone(); - - // Store the child process in state (kill any existing process for this tab) - { - let mut processes = process_arc.lock().await; - if let Some(mut existing) = processes.remove(&process_key) { - let _ = existing.kill().await; - } - processes.insert(process_key.clone(), child); - } - - let stdout_reader = BufReader::new(stdout); - let stderr_reader = BufReader::new(stderr); - let session_id_holder: Arc>> = - Arc::new(std::sync::Mutex::new(None)); - - let start_time = std::time::Instant::now(); - - // Spawn stdout streaming task — emit only to the originating window - let win_stdout = window.clone(); - let session_id_stdout = session_id_holder.clone(); - let tab_id_stdout = tab_id.clone(); - let stdout_task = tokio::spawn(async move { - let mut lines = stdout_reader.lines(); - let mut line_count: u64 = 0; - while let Ok(Some(line)) = lines.next_line().await { - line_count += 1; - let elapsed = start_time.elapsed().as_secs_f64(); - - // Parse for system:init to extract session_id - if let Ok(msg) = serde_json::from_str::(&line) { - let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("?"); - let msg_sub = msg.get("subtype").and_then(|v| v.as_str()).unwrap_or(""); - eprintln!( - "[claude-stdout] [{}] +{:.1}s #{} type={} sub={} len={}", - tab_id_stdout, - elapsed, - line_count, - msg_type, - msg_sub, - line.len() - ); - - if msg.get("type").and_then(|v| v.as_str()) == Some("system") - && msg.get("subtype").and_then(|v| v.as_str()) == Some("init") - { - if let Some(sid) = msg.get("session_id").and_then(|v| v.as_str()) { - if let Ok(mut guard) = session_id_stdout.lock() { - *guard = Some(sid.to_string()); - } - } - } - } - - // Emit output event to this window with tab_id - let _ = win_stdout.emit( - "claude-output", - ClaudeOutputEvent { - tab_id: tab_id_stdout.clone(), - data: line, - }, - ); - } - eprintln!( - "[claude-stdout] [{}] stream ended after {} lines ({:.1}s)", - tab_id_stdout, - line_count, - start_time.elapsed().as_secs_f64() - ); - }); - - // Spawn stderr streaming task — emit only to the originating window - let win_stderr = window.clone(); - let tab_id_stderr = tab_id.clone(); - let stderr_task = tokio::spawn(async move { - let mut lines = stderr_reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - eprintln!( - "[claude-stderr] [{}] +{:.1}s {}", - tab_id_stderr, - start_time.elapsed().as_secs_f64(), - &line[..line.len().min(200)] - ); - let _ = win_stderr.emit( - "claude-error", - ClaudeErrorEvent { - tab_id: tab_id_stderr.clone(), - data: line, - }, - ); - } - }); - - // Spawn wait task — wait for process completion - let process_arc_wait = process_arc.clone(); - let win_wait = window; - let process_key_wait = process_key; - let tab_id_wait = tab_id; - tokio::spawn(async move { - // Wait for stdout/stderr to finish - let _ = stdout_task.await; - let _ = stderr_task.await; - - // Wait for process exit and remove from map - let mut processes = process_arc_wait.lock().await; - let success = if let Some(mut child) = processes.remove(&process_key_wait) { - match child.wait().await { - Ok(status) => { - eprintln!( - "[claude-process] [{}] exited with status={} ({:.1}s)", - tab_id_wait, - status, - start_time.elapsed().as_secs_f64() - ); - status.success() - } - Err(e) => { - eprintln!( - "[claude-process] [{}] wait error: {} ({:.1}s)", - tab_id_wait, - e, - start_time.elapsed().as_secs_f64() - ); - false - } - } - } else { - eprintln!( - "[claude-process] [{}] no child found in map ({:.1}s)", - tab_id_wait, - start_time.elapsed().as_secs_f64() - ); - false - }; - drop(processes); - - // Emit completion event to this window with tab_id - let _ = win_wait.emit( - "claude-complete", - ClaudeCompleteEvent { - tab_id: tab_id_wait, - success, - }, - ); - }); - - Ok(()) -} - -// ─── Setup / Status Commands ─── +// 鈹€鈹€鈹€ Setup / Status Commands 鈹€鈹€鈹€ #[derive(serde::Serialize)] pub struct ClaudeStatus { @@ -1025,7 +2007,11 @@ pub struct ClaudeStatus { pub authenticated: bool, pub binary_path: Option, pub version: Option, + pub provider_kind: String, pub account_email: Option, + pub provider_model: Option, + pub provider_base_url: Option, + pub claude_provider_configured: bool, /// Windows only: true when Git for Windows (git-bash) is not found. /// Claude Code requires git-bash to function on Windows. pub missing_git: bool, @@ -1054,9 +2040,9 @@ fn find_git_bash() -> Option { } } - // 3. git on PATH → derive bash.exe location + // 3. git on PATH 鈫?derive bash.exe location if let Ok(git_path) = which::which("git") { - // git.exe is typically at Git/cmd/git.exe → bash.exe at Git/bin/bash.exe + // git.exe is typically at Git/cmd/git.exe 鈫?bash.exe at Git/bin/bash.exe if let Some(cmd_dir) = git_path.parent() { if let Some(git_root) = cmd_dir.parent() { let bash = git_root.join("bin").join("bash.exe"); @@ -1077,7 +2063,28 @@ fn find_git_bash() -> Option { #[tauri::command] pub async fn check_claude_status() -> Result { - // On Windows, check for Git for Windows first — Claude Code requires it. + let auth_config = read_claude_prism_auth_config()?; + let claude_provider_configured = stored_claude_credential_from_config(&auth_config).is_some() + || std::env::var("ANTHROPIC_API_KEY") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + || std::env::var("ANTHROPIC_AUTH_TOKEN") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + let openai_credential = stored_openai_compatible_credential_from_config(&auth_config, None); + let provider_kind = if openai_credential.is_some() { + PROVIDER_OPENAI_COMPATIBLE + } else { + PROVIDER_CLAUDE_CODE + }; + let provider_model = openai_credential + .as_ref() + .map(|credential| credential.model.clone()); + let provider_base_url = openai_credential + .as_ref() + .map(|credential| credential.base_url.clone()); + + // On Windows, check for Git for Windows first 鈥?Claude Code requires it. #[cfg(target_os = "windows")] let missing_git = find_git_bash().is_none(); #[cfg(not(target_os = "windows"))] @@ -1092,7 +2099,11 @@ pub async fn check_claude_status() -> Result { authenticated: false, binary_path: None, version: None, + provider_kind: provider_kind.to_string(), account_email: None, + provider_model: provider_model.clone(), + provider_base_url: provider_base_url.clone(), + claude_provider_configured, missing_git, }); } @@ -1106,19 +2117,38 @@ pub async fn check_claude_status() -> Result { Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) } _ => { - // Binary found but doesn't work — on Windows this is often because + // Binary found but doesn't work 鈥?on Windows this is often because // Git for Windows is missing (Claude Code needs git-bash). return Ok(ClaudeStatus { installed: false, authenticated: false, binary_path: None, version: None, + provider_kind: provider_kind.to_string(), account_email: None, + provider_model: provider_model.clone(), + provider_base_url: provider_base_url.clone(), + claude_provider_configured, missing_git, }); } }; + if openai_credential.is_some() { + return Ok(ClaudeStatus { + installed: true, + authenticated: true, + binary_path: Some(binary_path), + version, + provider_kind: PROVIDER_OPENAI_COMPATIBLE.to_string(), + account_email: None, + provider_model, + provider_base_url, + claude_provider_configured, + missing_git, + }); + } + // Check auth status let auth_output = new_sync_command(&binary_path) .args(["auth", "status"]) @@ -1127,7 +2157,7 @@ pub async fn check_claude_status() -> Result { let (authenticated, account_email) = match auth_output { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - // Parse for email — claude auth status outputs account info + // Parse for email 鈥?claude auth status outputs account info let email = stdout.lines().find(|line| line.contains('@')).map(|line| { // Extract email-like substring line.split_whitespace() @@ -1137,7 +2167,10 @@ pub async fn check_claude_status() -> Result { }); (true, email) } - _ => (false, None), + _ => match claude_credential_label() { + Some(label) => (true, Some(label.to_string())), + None => (false, None), + }, }; Ok(ClaudeStatus { @@ -1145,7 +2178,11 @@ pub async fn check_claude_status() -> Result { authenticated, binary_path: Some(binary_path), version, + provider_kind: PROVIDER_CLAUDE_CODE.to_string(), account_email, + provider_model: None, + provider_base_url: None, + claude_provider_configured, missing_git, }) } @@ -1224,7 +2261,7 @@ async fn ensure_local_dirs(window: &WebviewWindow) -> Result<(), String> { return Ok(()); } - // Need elevation — use osascript directly for reliability + // Need elevation 鈥?use osascript directly for reliability let user = std::env::var("USER").unwrap_or_default(); let local_dir = home.join(".local"); let script = build_elevation_script(&required_dirs, &user, &local_dir); @@ -1259,7 +2296,7 @@ async fn ensure_local_dirs(window: &WebviewWindow) -> Result<(), String> { } #[tauri::command] -pub async fn install_claude_cli(window: WebviewWindow) -> Result<(), String> { +pub async fn install_claude_cli(window: WebviewWindow) -> Result { // Ensure directories that the Claude Code installer expects exist. // The installer fails with EACCES if ~/.local is owned by root // (e.g. created by pip or another tool). @@ -1276,7 +2313,6 @@ pub async fn install_claude_cli(window: WebviewWindow) -> Result<(), String> { }; #[cfg(target_os = "windows")] let mut cmd = { - let mut c = tokio::process::Command::new("powershell"); c.creation_flags(CREATE_NO_WINDOW); c.args([ @@ -1319,6 +2355,7 @@ pub async fn install_claude_cli(window: WebviewWindow) -> Result<(), String> { cmd.env(&key, &value); } } + apply_proxy_env_to_command(&mut cmd, Some(&window)); let mut child = cmd .spawn() @@ -1350,21 +2387,31 @@ pub async fn install_claude_cli(window: WebviewWindow) -> Result<(), String> { } }); - // Wait for completion and emit result - let win_complete = window; - tokio::spawn(async move { - let _ = stdout_task.await; - let _ = stderr_task.await; - - let success = match child.wait().await { - Ok(status) => status.success(), - Err(_) => false, + let success = + match tokio::time::timeout(std::time::Duration::from_secs(600), child.wait()).await { + Ok(Ok(status)) => status.success(), + Ok(Err(err)) => { + let _ = window.emit( + "install-error", + format!("Claude Code installer failed to exit cleanly: {}", err), + ); + false + } + Err(_) => { + let _ = window.emit( + "install-error", + "Claude Code installer timed out after 10 minutes.", + ); + let _ = child.kill().await; + false + } }; - let _ = win_complete.emit("install-complete", success); - }); + let _ = stdout_task.await; + let _ = stderr_task.await; + let _ = window.emit("install-complete", success); - Ok(()) + Ok(success) } #[tauri::command] @@ -1380,7 +2427,6 @@ pub async fn login_claude(window: WebviewWindow) -> Result<(), String> { #[cfg(target_os = "windows")] let mut cmd = { - let (resolved, prefix) = resolve_cmd_to_node(&binary_path); let mut c = tokio::process::Command::new(&resolved); c.creation_flags(CREATE_NO_WINDOW); @@ -1460,7 +2506,7 @@ pub async fn login_claude(window: WebviewWindow) -> Result<(), String> { Ok(Ok(status)) => status.success(), Ok(Err(_)) => false, Err(_) => { - // Timeout — kill the stuck process + // Timeout 鈥?kill the stuck process let _ = child_for_timeout.lock().await.kill().await; false } @@ -1485,7 +2531,7 @@ fn common_claude_args() -> Vec { "Follow these rules strictly:\n", "1. PLANNING FIRST: Before making changes, use TodoWrite to create a step-by-step plan. ", "Break large tasks into small, incremental steps (one section or one logical unit per step).\n", - "2. INCREMENTAL EDITS: Use the Edit tool to make small, targeted changes — one step at a time. ", + "2. INCREMENTAL EDITS: Use the Edit tool to make small, targeted changes 鈥?one step at a time. ", "NEVER write or rewrite an entire file at once. Always prefer editing existing content over replacing it wholesale.\n", "3. STEP BY STEP: After each edit, mark the todo item as completed, then proceed to the next step. ", "This lets the user review changes incrementally.\n", @@ -1501,7 +2547,640 @@ fn common_claude_args() -> Vec { ] } -// ─── Tauri Commands ─── +// 鈹€鈹€鈹€ Tauri Commands 鈹€鈹€鈹€ + +fn openai_chat_completions_url(base_url: &str) -> String { + let clean = base_url.trim_end_matches('/'); + if clean.ends_with("/chat/completions") { + clean.to_string() + } else if openai_compatible_base_url_has_chat_root(clean) { + format!("{}/chat/completions", clean) + } else { + format!("{}/v1/chat/completions", clean) + } +} + +fn with_optional_bearer_auth( + request: reqwest::RequestBuilder, + api_key: &str, +) -> reqwest::RequestBuilder { + if api_key.trim().is_empty() { + request + } else { + request.bearer_auth(api_key) + } +} + +fn with_optional_anthropic_key( + request: reqwest::RequestBuilder, + api_key: &str, +) -> reqwest::RequestBuilder { + if api_key.trim().is_empty() { + request + } else { + request.header("x-api-key", api_key).bearer_auth(api_key) + } +} + +fn openai_models_url(base_url: &str) -> String { + let clean = base_url.trim_end_matches('/'); + if let Some(origin) = deepseek_origin_for_anthropic_base_url(clean) { + return format!("{}/models", origin); + } + if let Some(origin) = qwen_origin_for_anthropic_base_url(clean) { + return format!("{}/compatible-mode/v1/models", origin); + } + if let Some(origin) = moonshot_origin_for_anthropic_base_url(clean) { + return format!("{}/v1/models", origin); + } + if let Some(root) = clean.strip_suffix("/chat/completions") { + return format!("{}/models", root.trim_end_matches('/')); + } + + if openai_compatible_base_url_has_chat_root(clean) { + format!("{}/models", clean) + } else { + format!("{}/v1/models", clean) + } +} + +fn openai_compatible_base_url_has_chat_root(base_url: &str) -> bool { + let lower = base_url.to_ascii_lowercase(); + if lower == "https://api.deepseek.com" { + return true; + } + + let path = lower + .split_once("://") + .and_then(|(_, rest)| rest.split_once('/').map(|(_, path)| path)) + .unwrap_or("") + .trim_matches('/'); + if path.is_empty() { + return false; + } + + let segments = path.split('/').collect::>(); + let last = segments.last().copied().unwrap_or_default(); + matches!(last, "v1" | "v2" | "v3" | "v4" | "beta") + || path.ends_with("/openai") + || path.ends_with("compatible-mode/v1") +} + +fn openai_compatible_verification_body(model: &str) -> serde_json::Value { + json!({ + "model": model, + "messages": [{ + "role": "user", + "content": "Reply with exactly: ok", + }], + "stream": false, + }) +} + +fn anthropic_messages_url(base_url: &str) -> String { + let clean = base_url.trim_end_matches('/'); + if clean.ends_with("/v1/messages") || clean.ends_with("/messages") { + clean.to_string() + } else if clean.ends_with("/v1") { + format!("{}/messages", clean) + } else { + format!("{}/v1/messages", clean) + } +} + +fn anthropic_verification_body(model: &str) -> serde_json::Value { + json!({ + "model": model, + "max_tokens": 16, + "messages": [{ + "role": "user", + "content": "Reply with exactly: ok", + }], + "stream": false, + }) +} + +fn anthropic_response_has_message_content(response: &Value) -> bool { + response + .get("content") + .and_then(|value| value.as_array()) + .is_some_and(|content| { + content.iter().any(|block| { + block.get("text").and_then(|value| value.as_str()).is_some() + || block + .get("type") + .and_then(|value| value.as_str()) + .is_some_and(|block_type| matches!(block_type, "tool_use" | "thinking")) + }) + }) +} + +fn provider_error_excerpt(body: &str) -> String { + let compact = body.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= 500 { + return compact; + } + let truncated: String = compact.chars().take(500).collect(); + format!("{}...", truncated) +} + +fn openai_compatible_verification_error(status: reqwest::StatusCode, body: &str) -> String { + let detail = provider_error_excerpt(body); + let hint = match status { + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => { + "Invalid provider API key or missing model access." + } + reqwest::StatusCode::NOT_FOUND => { + "Provider endpoint or model was not found. Check the Base URL and model name." + } + reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNPROCESSABLE_ENTITY => { + "Provider rejected the request. Check the Base URL and model name." + } + reqwest::StatusCode::TOO_MANY_REQUESTS => { + "Provider rate limited the verification request. Try again later." + } + _ => "Provider verification failed.", + }; + if detail.is_empty() { + format!("{} (HTTP {})", hint, status) + } else { + format!("{} (HTTP {}: {})", hint, status, detail) + } +} + +async fn verify_openai_compatible_credential( + credential: &StoredOpenAiCompatibleCredential, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|err| format!("Failed to create provider client: {}", err))?; + + if let Some(anthropic_base_url) = native_anthropic_base_url(credential) { + return verify_native_anthropic_credential(&client, credential, &anthropic_base_url).await; + } + + let request_body = openai_compatible_verification_body(&credential.model); + + let request = client + .post(openai_chat_completions_url(&credential.base_url)) + .header("Content-Type", "application/json") + .body(request_body.to_string()); + let response = with_optional_bearer_auth(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider verification request failed: {}", err))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider verification response: {}", err))?; + + if !status.is_success() { + return Err(openai_compatible_verification_error(status, &response_text)); + } + + let response_json: serde_json::Value = serde_json::from_str(&response_text).map_err(|err| { + format!( + "Provider returned invalid JSON during verification: {}", + err + ) + })?; + if response_json.pointer("/choices/0/message").is_none() + && response_json.pointer("/choices/0/text").is_none() + { + return Err( + "Provider verification succeeded but did not return an OpenAI-compatible chat response." + .to_string(), + ); + } + + Ok(()) +} + +async fn verify_native_anthropic_credential( + client: &reqwest::Client, + credential: &StoredOpenAiCompatibleCredential, + anthropic_base_url: &str, +) -> Result<(), String> { + let request_body = anthropic_verification_body(&credential.model); + let request = client + .post(anthropic_messages_url(anthropic_base_url)) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(request_body.to_string()); + let response = with_optional_anthropic_key(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider verification request failed: {}", err))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider verification response: {}", err))?; + + if !status.is_success() { + return Err(openai_compatible_verification_error(status, &response_text)); + } + + let response_json: serde_json::Value = serde_json::from_str(&response_text).map_err(|err| { + format!( + "Provider returned invalid JSON during verification: {}", + err + ) + })?; + if !anthropic_response_has_message_content(&response_json) { + let detail = provider_error_excerpt(&response_text); + return Err( + format!( + "Provider verification succeeded but did not return an Anthropic-compatible message response. Response: {}", + detail + ), + ); + } + + Ok(()) +} + +async fn send_openai_compatible_no_tools_text_request( + client: &reqwest::Client, + credential: &StoredOpenAiCompatibleCredential, + messages: &[serde_json::Value], +) -> Result<(String, String), String> { + if let Some(anthropic_base_url) = native_anthropic_base_url(credential) { + return send_native_anthropic_no_tools_text_request( + client, + credential, + messages, + &anthropic_base_url, + ) + .await; + } + + let request_body = json!({ + "model": credential.model.clone(), + "messages": messages, + "stream": false, + }); + + let request = client + .post(openai_chat_completions_url(&credential.base_url)) + .header("Content-Type", "application/json") + .body(request_body.to_string()); + let response = with_optional_bearer_auth(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider request failed: {}", err))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider response: {}", err))?; + if !status.is_success() { + return Err(format!( + "Provider returned HTTP {}: {}", + status, response_text + )); + } + + let response: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|err| format!("Provider returned invalid JSON: {}", err))?; + let message = response.pointer("/choices/0/message"); + let content = message + .and_then(|message| message.get("content")) + .and_then(|value| value.as_str()) + .or_else(|| { + response + .pointer("/choices/0/text") + .and_then(|value| value.as_str()) + }) + .unwrap_or_default() + .to_string(); + let reasoning = message + .and_then(|message| { + message + .get("reasoning_content") + .or_else(|| message.get("reasoning")) + }) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + + Ok((content, reasoning)) +} + +async fn send_native_anthropic_no_tools_text_request( + client: &reqwest::Client, + credential: &StoredOpenAiCompatibleCredential, + messages: &[serde_json::Value], + anthropic_base_url: &str, +) -> Result<(String, String), String> { + let request_body = json!({ + "model": credential.model.clone(), + "max_tokens": 128, + "messages": messages, + "stream": false, + }); + + let request = client + .post(anthropic_messages_url(anthropic_base_url)) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(request_body.to_string()); + let response = with_optional_anthropic_key(request, &credential.api_key) + .send() + .await + .map_err(|err| format!("Provider request failed: {}", err))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|err| format!("Failed to read provider response: {}", err))?; + if !status.is_success() { + return Err(format!( + "Provider returned HTTP {}: {}", + status, response_text + )); + } + + let response: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|err| format!("Provider returned invalid JSON: {}", err))?; + let content = response + .get("content") + .and_then(|value| value.as_array()) + .map(|blocks| { + blocks + .iter() + .filter_map(|block| block.get("text").and_then(|value| value.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let reasoning = response + .get("content") + .and_then(|value| value.as_array()) + .map(|blocks| { + blocks + .iter() + .filter_map(|block| block.get("thinking").and_then(|value| value.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + Ok((content, reasoning)) +} + +async fn execute_openai_compatible_via_claude_proxy( + window: WebviewWindow, + project_path: String, + prompt: String, + tab_id: String, + args_prefix: Vec, + effort_level: Option, + credential: StoredOpenAiCompatibleCredential, +) -> Result<(), String> { + let model_transformers = credential + .model_transformers + .get(&credential.model) + .cloned() + .unwrap_or_default(); + let proxy_url = start_openai_anthropic_proxy(OpenAiProxyCredential { + api_key: credential.api_key.clone(), + base_url: credential.base_url.clone(), + model: credential.model.clone(), + transformers: credential.transformers.clone(), + model_transformers, + }) + .await?; + let claude_path = find_claude_binary()?; + + let (mut args, stdin_payload) = with_prompt_transport(args_prefix, prompt); + args.push("--model".to_string()); + args.push("sonnet".to_string()); + args.extend(common_claude_args()); + + let mut cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); + clear_anthropic_provider_env(&mut cmd); + cmd.env("ANTHROPIC_API_KEY", "claude-prism-local-proxy"); + cmd.env("ANTHROPIC_BASE_URL", proxy_url); + cmd.env_remove("CLAUDE_MODEL"); + + spawn_claude_process( + window, + cmd, + tab_id, + stdin_payload, + Some(SpawnProviderMetadata { + provider: PROVIDER_OPENAI_COMPATIBLE, + provider_credential_id: credential.id, + model: credential.model, + }), + ) + .await +} + +async fn execute_openai_compatible_provider( + window: WebviewWindow, + project_path: String, + prompt: String, + tab_id: String, + args_prefix: Vec, + effort_level: Option, + credential: StoredOpenAiCompatibleCredential, +) -> Result<(), String> { + ensure_secure_known_provider_base_url(&credential.base_url)?; + + if uses_native_anthropic_route(&credential) { + return execute_openai_compatible_via_native_anthropic( + window, + project_path, + prompt, + tab_id, + args_prefix, + effort_level, + credential, + ) + .await; + } + + execute_openai_compatible_via_claude_proxy( + window, + project_path, + prompt, + tab_id, + args_prefix, + effort_level, + credential, + ) + .await +} + +async fn execute_openai_compatible_via_native_anthropic( + window: WebviewWindow, + project_path: String, + prompt: String, + tab_id: String, + args_prefix: Vec, + effort_level: Option, + credential: StoredOpenAiCompatibleCredential, +) -> Result<(), String> { + let anthropic_base_url = native_anthropic_base_url(&credential) + .ok_or_else(|| "Provider does not expose a native Anthropic endpoint".to_string())?; + let claude_path = find_claude_binary()?; + + let (mut args, stdin_payload) = with_prompt_transport(args_prefix, prompt); + args.extend(common_claude_args()); + + let mut cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); + apply_native_anthropic_provider_env(&mut cmd, &credential, &anthropic_base_url); + + spawn_claude_process( + window, + cmd, + tab_id, + stdin_payload, + Some(SpawnProviderMetadata { + provider: PROVIDER_OPENAI_COMPATIBLE, + provider_credential_id: credential.id, + model: credential.model, + }), + ) + .await +} + +fn apply_native_anthropic_provider_env( + cmd: &mut Command, + credential: &StoredOpenAiCompatibleCredential, + anthropic_base_url: &str, +) { + clear_anthropic_provider_env(cmd); + cmd.env("ANTHROPIC_BASE_URL", anthropic_base_url); + cmd.env("ANTHROPIC_AUTH_TOKEN", credential.api_key.as_str()); + cmd.env("ANTHROPIC_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_SMALL_FAST_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_DEFAULT_OPUS_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_DEFAULT_SONNET_MODEL", credential.model.as_str()); + cmd.env("ANTHROPIC_DEFAULT_HAIKU_MODEL", credential.model.as_str()); + cmd.env("CLAUDE_CODE_SUBAGENT_MODEL", credential.model.as_str()); + if native_anthropic_provider_kind(anthropic_base_url) == Some("moonshot") { + cmd.env("ENABLE_TOOL_SEARCH", "false"); + cmd.env("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "262144"); + } + cmd.env_remove("CLAUDE_MODEL"); +} + +fn uses_native_anthropic_route(credential: &StoredOpenAiCompatibleCredential) -> bool { + native_anthropic_base_url(credential).is_some() +} + +fn native_anthropic_base_url(credential: &StoredOpenAiCompatibleCredential) -> Option { + let origin = http_origin(&credential.base_url)?; + let lower_origin = origin.to_ascii_lowercase(); + if lower_origin == "https://api.deepseek.com" { + let lower = credential.base_url.to_ascii_lowercase(); + if let Some(index) = lower.find("/anthropic") { + return Some(format!("{}{}", &credential.base_url[..index], "/anthropic")); + } + + return Some(format!("{}/anthropic", origin)); + } + + if is_qwen_anthropic_origin(&lower_origin) { + let lower = credential.base_url.to_ascii_lowercase(); + if let Some(index) = lower.find("/apps/anthropic") { + return Some(format!( + "{}{}", + &credential.base_url[..index], + "/apps/anthropic" + )); + } + + return Some(format!("{}/apps/anthropic", origin)); + } + + if is_moonshot_anthropic_origin(&lower_origin) { + return Some(format!("{}/anthropic", MOONSHOT_OFFICIAL_ORIGIN)); + } + + None +} + +fn native_anthropic_provider_kind(base_url: &str) -> Option<&'static str> { + let origin = http_origin(base_url)?.to_ascii_lowercase(); + if origin == "https://api.deepseek.com" { + return Some("deepseek"); + } + if is_qwen_anthropic_origin(&origin) { + return Some("qwen"); + } + if is_moonshot_anthropic_origin(&origin) { + return Some("moonshot"); + } + None +} + +fn deepseek_origin_for_anthropic_base_url(base_url: &str) -> Option { + let lower = base_url.to_ascii_lowercase(); + let origin = http_origin(base_url)?; + if origin.to_ascii_lowercase() != "https://api.deepseek.com" || !lower.contains("/anthropic") { + return None; + } + Some(origin) +} + +fn qwen_origin_for_anthropic_base_url(base_url: &str) -> Option { + let lower = base_url.to_ascii_lowercase(); + let origin = http_origin(base_url)?; + if !is_qwen_anthropic_origin(&origin.to_ascii_lowercase()) { + return None; + } + if lower.contains("/apps/anthropic") || lower.contains("/compatible-mode/") { + return Some(origin); + } + None +} + +fn moonshot_origin_for_anthropic_base_url(base_url: &str) -> Option { + let lower = base_url.to_ascii_lowercase(); + let origin = http_origin(base_url)?; + if !is_moonshot_anthropic_origin(&origin.to_ascii_lowercase()) { + return None; + } + if lower.contains("/anthropic") || lower.ends_with("/v1") { + return Some(MOONSHOT_OFFICIAL_ORIGIN.to_string()); + } + None +} + +fn is_qwen_anthropic_origin(lower_origin: &str) -> bool { + matches!( + lower_origin, + "https://dashscope.aliyuncs.com" | "https://dashscope-intl.aliyuncs.com" + ) +} + +fn is_moonshot_anthropic_origin(lower_origin: &str) -> bool { + matches!( + lower_origin, + "https://api.moonshot.cn" | "https://api.moonshot.ai" + ) +} + +fn http_origin(value: &str) -> Option { + let value = value.trim().trim_end_matches('/'); + let scheme_end = value.find("://")?; + let after_scheme = &value[scheme_end + 3..]; + let host_end = after_scheme.find('/').unwrap_or(after_scheme.len()); + if host_end == 0 { + return None; + } + Some(value[..scheme_end + 3 + host_end].to_string()) +} #[tauri::command] pub async fn execute_claude_code( @@ -1511,7 +3190,28 @@ pub async fn execute_claude_code( tab_id: String, model: Option, effort_level: Option, + provider_credential_id: Option, + provider_model_override: Option, ) -> Result<(), String> { + if let Some(mut credential) = + stored_openai_compatible_credential_by_id(provider_credential_id.as_deref())? + { + if let Some(model) = normalize_provider_model_override(provider_model_override.as_deref())? + { + credential.model = model; + } + return execute_openai_compatible_provider( + window, + project_path, + prompt, + tab_id, + Vec::new(), + effort_level, + credential, + ) + .await; + } + let claude_path = find_claude_binary()?; let (mut args, stdin_payload) = with_prompt_transport(Vec::new(), prompt); @@ -1522,7 +3222,7 @@ pub async fn execute_claude_code( args.extend(common_claude_args()); let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); - spawn_claude_process(window, cmd, tab_id, stdin_payload).await + spawn_claude_process(window, cmd, tab_id, stdin_payload, None).await } #[tauri::command] @@ -1533,7 +3233,28 @@ pub async fn continue_claude_code( tab_id: String, model: Option, effort_level: Option, + provider_credential_id: Option, + provider_model_override: Option, ) -> Result<(), String> { + if let Some(mut credential) = + stored_openai_compatible_credential_by_id(provider_credential_id.as_deref())? + { + if let Some(model) = normalize_provider_model_override(provider_model_override.as_deref())? + { + credential.model = model; + } + return execute_openai_compatible_provider( + window, + project_path, + prompt, + tab_id, + vec!["-c".to_string()], + effort_level, + credential, + ) + .await; + } + let claude_path = find_claude_binary()?; let (mut args, stdin_payload) = with_prompt_transport(vec!["-c".to_string()], prompt); @@ -1544,7 +3265,7 @@ pub async fn continue_claude_code( args.extend(common_claude_args()); let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); - spawn_claude_process(window, cmd, tab_id, stdin_payload).await + spawn_claude_process(window, cmd, tab_id, stdin_payload, None).await } #[tauri::command] @@ -1556,7 +3277,28 @@ pub async fn resume_claude_code( tab_id: String, model: Option, effort_level: Option, + provider_credential_id: Option, + provider_model_override: Option, ) -> Result<(), String> { + if let Some(mut credential) = + stored_openai_compatible_credential_by_id(provider_credential_id.as_deref())? + { + if let Some(model) = normalize_provider_model_override(provider_model_override.as_deref())? + { + credential.model = model; + } + return execute_openai_compatible_provider( + window, + project_path, + prompt, + tab_id, + vec!["--resume".to_string(), session_id], + effort_level, + credential, + ) + .await; + } + let claude_path = find_claude_binary()?; let (mut args, stdin_payload) = @@ -1568,46 +3310,25 @@ pub async fn resume_claude_code( args.extend(common_claude_args()); let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); - spawn_claude_process(window, cmd, tab_id, stdin_payload).await + spawn_claude_process(window, cmd, tab_id, stdin_payload, None).await } #[tauri::command] pub async fn cancel_claude_execution(window: WebviewWindow, tab_id: String) -> Result<(), String> { - let window_label = window.label().to_string(); - let process_key = format!("{}:{}", window_label, tab_id); - let claude_state = window.state::(); - let mut processes = claude_state.processes.lock().await; - if let Some(mut child) = processes.remove(&process_key) { - let _ = child.kill().await; - let _ = window.emit( - "claude-complete", - ClaudeCompleteEvent { - tab_id, - success: false, - }, - ); - } - Ok(()) + stop_claude_process(window, tab_id, ClaudeStopMode::Terminate) + .await + .map(|_| ()) } -/// Kill all Claude processes associated with a specific window label. -/// Called when a window is destroyed. -pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) { - let mut processes = state.processes.lock().await; - let prefix = format!("{}:", window_label); - let keys_to_remove: Vec = processes - .keys() - .filter(|k| k.starts_with(&prefix)) - .cloned() - .collect(); - for key in keys_to_remove { - if let Some(mut child) = processes.remove(&key) { - let _ = child.kill().await; - } - } +#[tauri::command] +pub async fn interrupt_claude_execution( + window: WebviewWindow, + tab_id: String, +) -> Result { + stop_claude_process(window, tab_id, ClaudeStopMode::Interrupt).await } -// ─── Session Listing ─── +// 鈹€鈹€鈹€ Session Listing 鈹€鈹€鈹€ #[derive(serde::Serialize)] pub struct ClaudeSessionInfo { @@ -1616,9 +3337,23 @@ pub struct ClaudeSessionInfo { pub last_modified: i64, } +#[derive(serde::Deserialize, serde::Serialize)] +struct SessionTitleCache { + title: String, + source_modified: i64, + generated_at: i64, +} + +struct SessionCandidate { + session_id: String, + fallback_title: String, + title: Option, + last_modified: i64, +} + /// Resolve the Claude Code sessions directory for a given project path. /// Claude Code encodes paths by replacing all non-alphanumeric characters with '-'. -/// e.g. "/Users/dev/my_project" → "-Users-dev-my-project" +/// e.g. "/Users/dev/my_project" 鈫?"-Users-dev-my-project" fn get_sessions_dir(project_path: &str) -> Result { let home = dirs::home_dir().ok_or("Could not determine home directory")?; @@ -1635,7 +3370,276 @@ fn get_sessions_dir(project_path: &str) -> Result { Ok(home.join(".claude").join("projects").join(&encoded)) } -/// Clean raw user message text into a display title. +fn unique_session_migration_target(target: &Path) -> PathBuf { + if !target.exists() { + return target.to_path_buf(); + } + + let parent = target.parent().unwrap_or_else(|| Path::new("")); + let stem = target + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("session"); + let extension = target.extension().and_then(|value| value.to_str()); + + for index in 1..1000 { + let file_name = match extension { + Some(ext) => format!("{}-migrated-{}.{}", stem, index, ext), + None => format!("{}-migrated-{}", stem, index), + }; + let candidate = parent.join(file_name); + if !candidate.exists() { + return candidate; + } + } + + parent.join(format!( + "{}-migrated-{}", + stem, + chrono::Utc::now().timestamp() + )) +} + +fn move_session_entry(source: &Path, target: &Path) -> Result<(), String> { + let target = unique_session_migration_target(target); + match std::fs::rename(source, &target) { + Ok(()) => Ok(()), + Err(rename_err) => { + if source.is_dir() { + return Err(format!( + "Failed to move session directory {:?} to {:?}: {}", + source, target, rename_err + )); + } + std::fs::copy(source, &target).map_err(|copy_err| { + format!( + "Failed to copy session file {:?} to {:?}: {}", + source, target, copy_err + ) + })?; + std::fs::remove_file(source).map_err(|remove_err| { + format!( + "Copied session file to {:?}, but failed to remove old file {:?}: {}", + target, source, remove_err + ) + })?; + Ok(()) + } + } +} + +#[tauri::command] +pub async fn migrate_project_sessions( + old_project_path: String, + new_project_path: String, +) -> Result<(), String> { + let old_sessions_dir = get_sessions_dir(&old_project_path)?; + let new_sessions_dir = get_sessions_dir(&new_project_path)?; + + if old_sessions_dir == new_sessions_dir || !old_sessions_dir.exists() { + return Ok(()); + } + + if let Some(parent) = new_sessions_dir.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create Claude projects directory: {}", e))?; + } + + if !new_sessions_dir.exists() { + match std::fs::rename(&old_sessions_dir, &new_sessions_dir) { + Ok(()) => return Ok(()), + Err(err) => { + eprintln!( + "[session] failed to rename sessions dir {:?} -> {:?}: {}. Falling back to merge.", + old_sessions_dir, new_sessions_dir, err + ); + } + } + } + + std::fs::create_dir_all(&new_sessions_dir) + .map_err(|e| format!("Failed to create migrated sessions directory: {}", e))?; + + let entries = std::fs::read_dir(&old_sessions_dir) + .map_err(|e| format!("Failed to read old sessions directory: {}", e))?; + for entry in entries.flatten() { + let source = entry.path(); + let target = new_sessions_dir.join(entry.file_name()); + move_session_entry(&source, &target)?; + } + + let _ = std::fs::remove_dir(&old_sessions_dir); + Ok(()) +} + +fn truncate_session_title(text: &str, max_chars: usize) -> String { + if text.chars().count() > max_chars { + let truncated: String = text.chars().take(max_chars.saturating_sub(3)).collect(); + format!("{}...", truncated) + } else { + text.to_string() + } +} + +fn truncate_long_text(text: String, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text; + } + let mut truncated: String = text.chars().take(max_chars).collect(); + truncated.push_str("\n...[truncated]"); + truncated +} + +fn session_title_cache_path(session_path: &Path) -> PathBuf { + session_path.with_extension("title.json") +} + +fn sanitize_model_session_title(title: &str) -> Option { + let title = title + .lines() + .map(str::trim) + .find(|line| !line.is_empty())? + .trim_matches(|c| matches!(c, '"' | '\'' | '`')) + .trim(); + let title = title + .strip_prefix("Title:") + .or_else(|| title.strip_prefix("title:")) + .or_else(|| title.strip_prefix("鏍囬:")) + .unwrap_or(title) + .trim(); + let title = normalize_title_whitespace(title); + let lower = title.to_lowercase(); + if title.is_empty() + || lower == "new chat" + || lower == "untitled" + || lower == "untitled session" + || lower == "conversation summary" + || lower == "chat summary" + { + return None; + } + + Some(truncate_session_title(&title, 72)) +} + +fn read_session_title_cache(session_path: &Path) -> Option { + let cache_path = session_title_cache_path(session_path); + let content = std::fs::read_to_string(cache_path).ok()?; + let cache = serde_json::from_str::(&content).ok()?; + sanitize_model_session_title(&cache.title) +} + +fn write_session_title_cache( + session_path: &Path, + source_modified: i64, + title: &str, +) -> Result<(), String> { + let cache_path = session_title_cache_path(session_path); + let Some(title) = sanitize_model_session_title(title) else { + return Ok(()); + }; + let cache = SessionTitleCache { + title, + source_modified, + generated_at: chrono::Utc::now().timestamp(), + }; + let content = serde_json::to_string_pretty(&cache) + .map_err(|e| format!("Failed to serialize session title cache: {}", e))?; + std::fs::write(cache_path, content) + .map_err(|e| format!("Failed to write session title cache: {}", e)) +} + +fn normalize_title_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn is_noise_title_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return true; + } + + let lower = trimmed.to_lowercase(); + lower.starts_with("template:") + || lower.starts_with("file:") + || lower.starts_with("reference files") + || lower == "what i want to create" + || lower.starts_with("(extracted text") + || lower.starts_with("attachments/") + || lower.starts_with("the file currently contains") + || (lower.starts_with("new ") && lower.contains(" project")) +} + +fn extract_marked_request_body(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + let marker_index = lines + .iter() + .position(|line| line.trim().eq_ignore_ascii_case("what i want to create"))?; + + let mut selected = Vec::new(); + for line in lines.iter().skip(marker_index + 1) { + let trimmed = line.trim(); + if trimmed.eq_ignore_ascii_case("reference files") { + break; + } + if is_noise_title_line(trimmed) { + continue; + } + selected.push(trimmed); + if selected.join(" ").chars().count() >= 120 { + break; + } + } + + let body = normalize_title_whitespace(&selected.join(" ")); + if body.is_empty() { + None + } else { + Some(body) + } +} + +fn first_meaningful_title_line(text: &str) -> Option { + text.lines() + .map(str::trim) + .find(|line| !is_noise_title_line(line)) + .map(normalize_title_whitespace) + .filter(|line| !line.is_empty()) +} + +fn summarize_session_title(text: &str) -> Option { + let source = extract_marked_request_body(text).or_else(|| first_meaningful_title_line(text))?; + let normalized = normalize_title_whitespace(&source); + let lower = normalized.to_lowercase(); + + let research_prefix = [ + "a research paper for ", + "research paper for ", + "a research paper on ", + "research paper on ", + "a research paper about ", + "research paper about ", + ] + .into_iter() + .find(|prefix| lower.starts_with(prefix)); + + let summary = if let Some(prefix) = research_prefix { + let topic = normalized[prefix.len()..].trim(); + if topic.is_empty() { + "Research Paper".to_string() + } else { + format!("Research Paper: {}", truncate_session_title(topic, 56)) + } + } else if lower.contains("research paper") { + truncate_session_title(&normalized, 80) + } else { + truncate_session_title(&normalized, 80) + }; + + Some(summary) +} + +/// Clean raw user message text into a summarized display title. fn clean_user_message_title(text: &str) -> Option { // Skip IDE context tags if text.starts_with("") { @@ -1663,14 +3667,7 @@ fn clean_user_message_title(text: &str) -> Option { return None; } - let title = if clean.chars().count() > 80 { - let truncated: String = clean.chars().take(77).collect(); - format!("{}...", truncated) - } else { - clean.to_string() - }; - - Some(title) + summarize_session_title(clean) } /// Extract the first valid user message from a JSONL session file. @@ -1731,8 +3728,138 @@ fn extract_first_user_message(path: &PathBuf) -> (Option, Option (None, None) } +fn title_text_from_message_content(content: &serde_json::Value) -> Option { + if let Some(text) = content.as_str() { + return Some(text.to_string()); + } + + let blocks = content.as_array()?; + let mut parts = Vec::new(); + for block in blocks { + if block.get("type").and_then(|v| v.as_str()) != Some("text") { + continue; + } + if let Some(text) = block.get("text").and_then(|v| v.as_str()) { + parts.push(text.trim()); + } + } + + let text = normalize_title_whitespace(&parts.join("\n")); + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn session_excerpt_for_model_title(path: &Path) -> Option { + let file = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(file); + use std::io::BufRead; + + let mut lines = Vec::new(); + let mut total_chars = 0usize; + for line in reader.lines().map_while(Result::ok) { + let Ok(entry) = serde_json::from_str::(&line) else { + continue; + }; + let Some(kind) = entry.get("type").and_then(|v| v.as_str()) else { + continue; + }; + if kind != "user" && kind != "assistant" { + continue; + } + let Some(content) = entry + .get("message") + .and_then(|m| m.get("content")) + .or_else(|| entry.get("content")) + else { + continue; + }; + let Some(mut text) = title_text_from_message_content(content) else { + continue; + }; + text = text.trim().to_string(); + if text.is_empty() + || text.starts_with("") + || text.starts_with("") + || text.starts_with("") + { + continue; + } + + let speaker = if kind == "user" { "User" } else { "Assistant" }; + let text = truncate_long_text(text, 1400); + let entry = format!("{}: {}", speaker, text); + total_chars += entry.chars().count(); + lines.push(entry); + if lines.len() >= 8 || total_chars >= 6000 { + break; + } + } + + let excerpt = lines.join("\n\n"); + if excerpt.trim().is_empty() { + None + } else { + Some(excerpt) + } +} + +fn session_title_credential_from_config( + config: &ClaudePrismAuthConfig, +) -> Option { + let credentials = normalized_openai_compatible_credentials(config); + if let Some(active_id) = config.active_openai_credential_id.as_deref() { + if let Some(credential) = credentials + .iter() + .find(|credential| credential.id == active_id) + .cloned() + { + return Some(credential); + } + } + + credentials.into_iter().next() +} + +async fn generate_model_session_title( + client: &reqwest::Client, + credential: &StoredOpenAiCompatibleCredential, + excerpt: &str, +) -> Result, String> { + let messages = vec![ + json!({ + "role": "system", + "content": "You generate concise chat history titles. Return only the title text, with no quotes, no markdown, and no explanation.", + }), + json!({ + "role": "user", + "content": format!( + "Summarize this chat as a short history title. Use the user's language when obvious. Prefer a task/topic summary over copying the first sentence. Keep it under 8 English words or 16 Chinese characters. Avoid generic titles like New Chat or Research Paper.\n\nConversation excerpt:\n{}", + excerpt + ), + }), + ]; + + let (content, reasoning) = + send_openai_compatible_no_tools_text_request(client, credential, &messages).await?; + let title = if content.trim().is_empty() { + reasoning.trim() + } else { + content.trim() + }; + + Ok(sanitize_model_session_title(title)) +} + #[tauri::command] -pub async fn list_claude_sessions(project_path: String) -> Result, String> { +pub async fn list_claude_sessions( + project_path: String, + generate_titles: Option, +) -> Result, String> { + let _ = generate_titles; eprintln!( "[session] list_claude_sessions called with project_path={}", project_path @@ -1749,7 +3876,7 @@ pub async fn list_claude_sessions(project_path: String) -> Result Result = candidates + .into_iter() + .map(|candidate| ClaudeSessionInfo { + session_id: candidate.session_id, + title: candidate.title.unwrap_or(candidate.fallback_title), + last_modified: candidate.last_modified, + }) + .collect(); + sessions.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); eprintln!("[session] found {} sessions", sessions.len()); @@ -1802,12 +3942,76 @@ pub async fn list_claude_sessions(project_path: String) -> Result bool { + !session_id.is_empty() + && session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +#[tauri::command] +pub async fn generate_claude_session_title( + project_path: String, + session_id: String, +) -> Result, String> { + if !is_valid_session_id(&session_id) { + return Err("Invalid session id".to_string()); + } + + let sessions_dir = get_sessions_dir(&project_path)?; + let session_path = sessions_dir.join(format!("{}.jsonl", session_id)); + if !session_path.exists() { + return Ok(None); + } + + if let Some(title) = read_session_title_cache(&session_path) { + return Ok(Some(title)); + } + + let modified = std::fs::metadata(&session_path) + .ok() + .and_then(|m| m.modified().ok()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + }) + .unwrap_or(0); + + let Some(excerpt) = session_excerpt_for_model_title(&session_path) else { + return Ok(None); + }; + let Some(credential) = read_claude_prism_auth_config() + .ok() + .and_then(|config| session_title_credential_from_config(&config)) + else { + return Ok(None); + }; + let Ok(client) = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .build() + else { + return Ok(None); + }; + + let Some(title) = generate_model_session_title(&client, &credential, &excerpt).await? else { + return Ok(None); + }; + + write_session_title_cache(&session_path, modified, &title)?; + Ok(Some(title)) +} + /// Load the full JSONL history for a specific session. #[tauri::command] pub async fn load_session_history( project_path: String, session_id: String, ) -> Result, String> { + if !is_valid_session_id(&session_id) { + return Err("Invalid session id".to_string()); + } + eprintln!( "[session] load_session_history called: session_id={} project_path={}", session_id, project_path @@ -1846,7 +4050,42 @@ pub async fn load_session_history( Ok(messages) } -// ─── Shell Command Execution ─── +// 鈹€鈹€鈹€ Shell Command Execution 鈹€鈹€鈹€ + +#[tauri::command] +pub async fn delete_claude_session(project_path: String, session_id: String) -> Result<(), String> { + if !is_valid_session_id(&session_id) { + return Err("Invalid session id".to_string()); + } + + let sessions_dir = get_sessions_dir(&project_path)?; + let session_path = sessions_dir.join(format!("{}.jsonl", session_id)); + + if !session_path.exists() { + return Ok(()); + } + + let canonical_sessions_dir = sessions_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve sessions directory: {}", e))?; + let canonical_session_path = session_path + .canonicalize() + .map_err(|e| format!("Failed to resolve session file: {}", e))?; + + if !canonical_session_path.starts_with(&canonical_sessions_dir) { + return Err("Refusing to delete session outside project history".to_string()); + } + + if canonical_session_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + return Err("Refusing to delete non-session file".to_string()); + } + + std::fs::remove_file(&canonical_session_path) + .map_err(|e| format!("Failed to delete session: {}", e))?; + let _ = std::fs::remove_file(session_title_cache_path(&canonical_session_path)); + + Ok(()) +} #[derive(serde::Serialize)] pub struct ShellCommandResult { @@ -1855,31 +4094,63 @@ pub struct ShellCommandResult { pub stderr: String, } +fn truncate_shell_output(value: &[u8]) -> String { + const MAX_OUTPUT_BYTES: usize = 64 * 1024; + let truncated = value.len() > MAX_OUTPUT_BYTES; + let mut text = String::from_utf8_lossy(&value[..value.len().min(MAX_OUTPUT_BYTES)]).to_string(); + if truncated { + text.push_str("\n[output truncated]"); + } + text +} + #[tauri::command] pub async fn run_shell_command(command: String, cwd: String) -> Result { + let command = strip_nul(&command).trim().to_string(); + if command.is_empty() { + return Err("Command is empty".to_string()); + } + + let cwd = strip_nul(&cwd).trim().to_string(); + if cwd.is_empty() { + return Err("Command working directory is empty".to_string()); + } + let cwd = PathBuf::from(cwd); + let cwd = cwd + .canonicalize() + .map_err(|e| format!("Failed to resolve command working directory: {}", e))?; + if !cwd.is_dir() { + return Err("Command working directory is not a directory".to_string()); + } + let cwd = cwd.to_string_lossy().to_string(); + #[cfg(not(target_os = "windows"))] let (shell, args) = ("sh", vec!["-c".to_string(), command]); #[cfg(target_os = "windows")] let (shell, args) = ("cmd", vec!["/C".to_string(), command]); let mut cmd = create_command(shell, args, &cwd, None); + cmd.kill_on_drop(true); let child = cmd .spawn() .map_err(|e| format!("Failed to spawn command: {}", e))?; - let output = child - .wait_with_output() - .await - .map_err(|e| format!("Failed to wait for command: {}", e))?; + let output = tokio::time::timeout( + std::time::Duration::from_secs(120), + child.wait_with_output(), + ) + .await + .map_err(|_| "Command timed out after 120 seconds".to_string())? + .map_err(|e| format!("Failed to wait for command: {}", e))?; Ok(ShellCommandResult { exit_code: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), + stdout: truncate_shell_output(&output.stdout), + stderr: truncate_shell_output(&output.stderr), }) } -// ─── Claude Settings (fast mode, etc.) ─── +// 鈹€鈹€鈹€ Claude Settings (fast mode, etc.) 鈹€鈹€鈹€ fn get_claude_settings_path() -> Result { let home = dirs::home_dir().ok_or("Could not find home directory")?; @@ -1942,6 +4213,152 @@ pub async fn set_claude_fast_mode(enabled: bool) -> Result<(), String> { mod tests { use super::*; + fn test_openai_compatible_auth_config() -> ClaudePrismAuthConfig { + ClaudePrismAuthConfig { + provider: Some(PROVIDER_OPENAI_COMPATIBLE.to_string()), + active_openai_credential_id: Some("qwen".to_string()), + openai_credentials: vec![ + StoredOpenAiCompatibleCredentialConfig { + id: "qwen".to_string(), + label: "Qwen".to_string(), + api_key: "sk-qwen".to_string(), + base_url: "https://dashscope.aliyuncs.com/apps/anthropic".to_string(), + model: "qwen3-coder-plus".to_string(), + transformers: vec!["enhancetool".to_string()], + model_transformers: HashMap::new(), + }, + StoredOpenAiCompatibleCredentialConfig { + id: "deepseek".to_string(), + label: "DeepSeek".to_string(), + api_key: "sk-deepseek".to_string(), + base_url: "https://api.deepseek.com/anthropic".to_string(), + model: "deepseek-chat".to_string(), + transformers: vec!["deepseek".to_string()], + model_transformers: HashMap::from([( + "deepseek-chat".to_string(), + vec!["tooluse".to_string()], + )]), + }, + StoredOpenAiCompatibleCredentialConfig { + id: "moonshot".to_string(), + label: "Moonshot / Kimi".to_string(), + api_key: "sk-moonshot".to_string(), + base_url: "https://api.moonshot.ai/anthropic".to_string(), + model: "kimi-k2.5".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }, + ], + ..Default::default() + } + } + + #[test] + fn test_normalize_proxy_url_defaults_to_http() { + assert_eq!( + normalize_proxy_url("127.0.0.1:7890"), + Some("http://127.0.0.1:7890".to_string()) + ); + assert_eq!( + normalize_proxy_url("socks5://127.0.0.1:7891"), + Some("socks5://127.0.0.1:7891".to_string()) + ); + assert_eq!(normalize_proxy_url(" "), None); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_single_proxy_for_installer_env() { + let vars = parse_windows_proxy_server_for_env("127.0.0.1:7890"); + + assert_eq!( + vars, + vec![ + ProxyEnvVar { + key: "HTTPS_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "HTTP_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "ALL_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy".to_string(), + }, + ] + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_per_scheme_for_installer_env() { + let vars = parse_windows_proxy_server_for_env( + "http=127.0.0.1:7890;https=127.0.0.1:7891;socks=127.0.0.1:7892", + ); + + assert_eq!( + vars, + vec![ + ProxyEnvVar { + key: "HTTP_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy (http)".to_string(), + }, + ProxyEnvVar { + key: "HTTPS_PROXY", + value: "http://127.0.0.1:7891".to_string(), + source: "Windows system proxy (https)".to_string(), + }, + ProxyEnvVar { + key: "ALL_PROXY", + value: "socks5://127.0.0.1:7892".to_string(), + source: "Windows system proxy (socks)".to_string(), + }, + ] + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_windows_proxy_override_to_no_proxy_env() { + assert_eq!( + windows_proxy_override_to_no_proxy_env(";*.example.com;api.test"), + Some("localhost,127.0.0.1,::1,.example.com,api.test".to_string()) + ); + } + + #[test] + fn test_provider_model_override_ignores_claude_model_selectors() { + assert_eq!( + normalize_provider_model_override(Some("claude-opus-4-7")).unwrap(), + None + ); + assert_eq!( + normalize_provider_model_override(Some("opusplan")).unwrap(), + None + ); + assert_eq!( + normalize_provider_model_override(Some("sonnet")).unwrap(), + None + ); + } + + #[test] + fn test_provider_model_override_accepts_openai_compatible_models() { + assert_eq!( + normalize_provider_model_override(Some("qwen3-coder-plus")).unwrap(), + Some("qwen3-coder-plus".to_string()) + ); + assert_eq!( + normalize_provider_model_override(Some("deepseek-chat")).unwrap(), + Some("deepseek-chat".to_string()) + ); + } + // --- get_sessions_dir --- #[test] @@ -1972,6 +4389,16 @@ mod tests { assert_eq!(dir_name, "-a-b-c-d-e"); } + #[test] + fn test_session_id_validation_rejects_path_components() { + assert!(is_valid_session_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_valid_session_id("session_123")); + assert!(!is_valid_session_id("")); + assert!(!is_valid_session_id("../other-project/session")); + assert!(!is_valid_session_id("..\\other-project\\session")); + assert!(!is_valid_session_id("session.jsonl")); + } + // --- clean_user_message_title --- #[test] @@ -2002,6 +4429,16 @@ mod tests { assert_eq!(result, Some("Fix the bibliography".to_string())); } + #[test] + fn test_clean_user_message_title_summarizes_project_wizard_prompt() { + let text = "New IEEE Conference Paper Project\nTemplate: IEEEtran\nFile: main.tex\nThe file currently contains only the LaTeX preamble.\nWhat I want to create\nA research paper for vllm acceleration on FastGraphVID and GraphSTM\nReference Files\nattachments/Very_Long_File_Name.pdf\n(extracted text:"; + let result = clean_user_message_title(text); + assert_eq!( + result, + Some("Research Paper: vllm acceleration on FastGraphVID and GraphSTM".to_string()) + ); + } + #[test] fn test_clean_user_message_title_truncates_at_80() { let long_text = "a".repeat(100); @@ -2048,28 +4485,457 @@ mod tests { #[test] fn test_with_prompt_transport_always_includes_print_flag() { - let (args, stdin_payload) = - with_prompt_transport(vec!["--resume".to_string(), "abc".to_string()], "hello 文件".into()); + let (args, stdin_payload) = with_prompt_transport( + vec!["--resume".to_string(), "abc".to_string()], + "hello 鏂囦欢".into(), + ); assert!(args.contains(&"-p".to_string())); #[cfg(target_os = "windows")] { - assert_eq!(stdin_payload.as_deref(), Some("hello 文件")); - assert!(!args.contains(&"hello 文件".to_string())); + assert_eq!(stdin_payload.as_deref(), Some("hello 鏂囦欢")); + assert!(!args.contains(&"hello 鏂囦欢".to_string())); } #[cfg(not(target_os = "windows"))] { assert_eq!(stdin_payload, None); - assert_eq!(args.last().map(String::as_str), Some("hello 文件")); + assert_eq!(args.last().map(String::as_str), Some("hello 鏂囦欢")); } } + #[test] + fn test_claude_external_proxy_sets_api_key_and_auth_token_envs() { + let credential = StoredClaudeCredential { + api_key: "sk-modelgate".to_string(), + base_url: Some("https://mg.aid.pub/claude-proxy".to_string()), + }; + let values = claude_credential_env_values(&credential); + + assert!(values.contains(&("ANTHROPIC_API_KEY", "sk-modelgate".to_string()))); + assert!(values.contains(&( + "ANTHROPIC_BASE_URL", + "https://mg.aid.pub/claude-proxy".to_string() + ))); + assert!(values.contains(&("ANTHROPIC_AUTH_TOKEN", "sk-modelgate".to_string()))); + } + + #[test] + fn test_claude_direct_anthropic_key_does_not_set_auth_token() { + let credential = StoredClaudeCredential { + api_key: "sk-ant-test".to_string(), + base_url: None, + }; + let values = claude_credential_env_values(&credential); + + assert!(values.contains(&("ANTHROPIC_API_KEY", "sk-ant-test".to_string()))); + assert!(!values.iter().any(|(key, _)| *key == "ANTHROPIC_AUTH_TOKEN")); + assert!(!values.iter().any(|(key, _)| *key == "ANTHROPIC_BASE_URL")); + } + + #[test] + fn test_claude_credential_is_available_when_openai_provider_is_active() { + let mut config = test_openai_compatible_auth_config(); + config.anthropic_api_key = Some("sk-modelgate".to_string()); + config.anthropic_base_url = Some("https://mg.aid.pub/claude-proxy".to_string()); + + let credential = stored_claude_credential_from_config(&config).unwrap(); + + assert_eq!(credential.api_key, "sk-modelgate"); + assert_eq!( + credential.base_url.as_deref(), + Some("https://mg.aid.pub/claude-proxy") + ); + } + + #[test] + fn test_known_proxy_mismatch_rejects_modelgate_codex_proxy() { + let error = known_proxy_mismatch_error( + PROVIDER_OPENAI_COMPATIBLE, + Some("https://mg.aid.pub/codex-proxy"), + ) + .unwrap(); + + assert!(error.contains("codex-proxy")); + assert!(error.contains("Responses API")); + assert!(error.contains("chat/completions")); + } + + #[test] + fn test_known_proxy_mismatch_rejects_claude_proxy_as_openai_compatible() { + let error = known_proxy_mismatch_error( + PROVIDER_OPENAI_COMPATIBLE, + Some("https://mg.aid.pub/claude-proxy"), + ) + .unwrap(); + + assert!(error.contains("Claude-compatible proxy")); + assert!(error.contains("Claude Code / Anthropic API")); + } + + #[test] + fn test_known_proxy_mismatch_allows_claude_proxy_for_claude_provider() { + assert!(known_proxy_mismatch_error( + PROVIDER_CLAUDE_CODE, + Some("https://mg.aid.pub/claude-proxy"), + ) + .is_none()); + } + + #[test] + fn test_provider_id_lookup_none_does_not_fallback_to_active_provider() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, None).unwrap(); + + assert!(credential.is_none()); + } + + #[test] + fn test_provider_id_lookup_uses_explicit_provider() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, Some("deepseek")) + .unwrap() + .unwrap(); + + assert_eq!(credential.id, "deepseek"); + assert_eq!(credential.model, "deepseek-chat"); + } + + #[test] + fn test_deepseek_official_root_uses_native_anthropic_route() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, Some("deepseek")) + .unwrap() + .unwrap(); + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.deepseek.com/anthropic") + ); + } + + #[test] + fn test_qwen_official_anthropic_endpoint_uses_native_route() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, Some("qwen")) + .unwrap() + .unwrap(); + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://dashscope.aliyuncs.com/apps/anthropic") + ); + } + + #[test] + fn test_moonshot_official_anthropic_endpoint_uses_native_route() { + let config = test_openai_compatible_auth_config(); + let credential = openai_compatible_credential_by_id_from_config(&config, Some("moonshot")) + .unwrap() + .unwrap(); + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.moonshot.ai/anthropic") + ); + } + + #[test] + fn test_native_anthropic_route_preserves_explicit_anthropic_path() { + let credential = StoredOpenAiCompatibleCredential { + id: "deepseek-anthropic".to_string(), + label: "DeepSeek".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://api.deepseek.com/anthropic/v1".to_string(), + model: "deepseek-v4-pro".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.deepseek.com/anthropic") + ); + } + + #[test] + fn test_non_deepseek_anthropic_path_stays_on_proxy_route() { + let credential = StoredOpenAiCompatibleCredential { + id: "other-anthropic".to_string(), + label: "Other".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://router.example.com/anthropic".to_string(), + model: "qwen3".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(!uses_native_anthropic_route(&credential)); + assert_eq!(native_anthropic_base_url(&credential), None); + } + + #[test] + fn test_qwen_legacy_compatible_mode_uses_native_anthropic_route() { + let credential = StoredOpenAiCompatibleCredential { + id: "qwen-compatible".to_string(), + label: "Qwen".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1".to_string(), + model: "qwen3-max-2026-01-23".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://dashscope.aliyuncs.com/apps/anthropic") + ); + } + + #[test] + fn test_moonshot_legacy_openai_mode_uses_native_anthropic_route() { + let credential = StoredOpenAiCompatibleCredential { + id: "moonshot-compatible".to_string(), + label: "Moonshot / Kimi".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://api.moonshot.ai/v1".to_string(), + model: "kimi-k2.5".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.moonshot.ai/anthropic") + ); + } + + #[test] + fn test_moonshot_cn_urls_normalize_to_official_ai_route() { + let credential = StoredOpenAiCompatibleCredential { + id: "moonshot-compatible".to_string(), + label: "Moonshot / Kimi".to_string(), + api_key: "sk-test".to_string(), + base_url: "https://api.moonshot.cn/v1".to_string(), + model: "kimi-k2.5".to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(uses_native_anthropic_route(&credential)); + assert_eq!( + native_anthropic_base_url(&credential).as_deref(), + Some("https://api.moonshot.ai/anthropic") + ); + } + + #[test] + fn test_known_native_provider_routes_require_https() { + for (base_url, model) in [ + ("http://api.deepseek.com/anthropic", "deepseek-chat"), + ("http://dashscope.aliyuncs.com/apps/anthropic", "qwen3-max"), + ("http://api.moonshot.ai/anthropic", "kimi-k2.5"), + ] { + let credential = StoredOpenAiCompatibleCredential { + id: "insecure".to_string(), + label: "Insecure".to_string(), + api_key: "sk-test".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + transformers: Vec::new(), + model_transformers: HashMap::new(), + }; + + assert!(!uses_native_anthropic_route(&credential)); + assert!(ensure_secure_known_provider_base_url(base_url).is_err()); + } + + assert!(ensure_secure_known_provider_base_url("http://localhost:11434/v1").is_ok()); + } + + #[test] + fn test_provider_id_lookup_rejects_missing_provider() { + let config = test_openai_compatible_auth_config(); + let error = + openai_compatible_credential_by_id_from_config(&config, Some("missing")).unwrap_err(); + + assert!(error.contains("Configured provider credential not found")); + } + + #[test] + fn test_openai_chat_completions_url_preserves_full_endpoint() { + assert_eq!( + openai_chat_completions_url("https://open.bigmodel.cn/api/paas/v4/chat/completions"), + "https://open.bigmodel.cn/api/paas/v4/chat/completions" + ); + } + + #[test] + fn test_openai_chat_completions_url_supports_common_provider_roots() { + assert_eq!( + openai_chat_completions_url("https://dashscope.aliyuncs.com/compatible-mode/v1"), + "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + ); + assert_eq!( + openai_chat_completions_url("https://generativelanguage.googleapis.com/v1beta/openai/"), + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + ); + assert_eq!( + openai_chat_completions_url("https://open.bigmodel.cn/api/paas/v4"), + "https://open.bigmodel.cn/api/paas/v4/chat/completions" + ); + assert_eq!( + openai_chat_completions_url("https://api.deepseek.com"), + "https://api.deepseek.com/chat/completions" + ); + } + + #[test] + fn test_anthropic_messages_url_matches_deepseek_native_base() { + assert_eq!( + anthropic_messages_url("https://api.deepseek.com/anthropic"), + "https://api.deepseek.com/anthropic/v1/messages" + ); + assert_eq!( + anthropic_messages_url("https://api.deepseek.com/anthropic/v1"), + "https://api.deepseek.com/anthropic/v1/messages" + ); + } + + #[test] + fn test_anthropic_response_has_message_content_with_thinking_block() { + let response = serde_json::json!({ + "content": [{ + "type": "thinking", + "thinking": "We need to output ok." + }] + }); + + assert!(anthropic_response_has_message_content(&response)); + } + + #[test] + fn test_anthropic_response_has_message_content_with_empty_content() { + let response = serde_json::json!({}); + + assert!(!anthropic_response_has_message_content(&response)); + } + + #[test] + fn test_openai_chat_completions_url_keeps_generic_openai_default() { + assert_eq!( + openai_chat_completions_url("https://api.openai.com"), + "https://api.openai.com/v1/chat/completions" + ); + assert_eq!( + openai_chat_completions_url("https://openrouter.ai/api/v1"), + "https://openrouter.ai/api/v1/chat/completions" + ); + } + + #[test] + fn test_openai_chat_completions_url_supports_ollama_roots() { + assert_eq!( + openai_chat_completions_url("http://localhost:11434"), + "http://localhost:11434/v1/chat/completions" + ); + assert_eq!( + openai_chat_completions_url("http://localhost:11434/v1"), + "http://localhost:11434/v1/chat/completions" + ); + } + + #[test] + fn test_openai_models_url_matches_provider_roots() { + assert_eq!( + openai_models_url("https://dashscope.aliyuncs.com/compatible-mode/v1"), + "https://dashscope.aliyuncs.com/compatible-mode/v1/models" + ); + assert_eq!( + openai_models_url("https://dashscope.aliyuncs.com/apps/anthropic"), + "https://dashscope.aliyuncs.com/compatible-mode/v1/models" + ); + assert_eq!( + openai_models_url("https://api.moonshot.cn/anthropic"), + "https://api.moonshot.ai/v1/models" + ); + assert_eq!( + openai_models_url("https://api.moonshot.ai/anthropic"), + "https://api.moonshot.ai/v1/models" + ); + assert_eq!( + openai_models_url("https://generativelanguage.googleapis.com/v1beta/openai/"), + "https://generativelanguage.googleapis.com/v1beta/openai/models" + ); + assert_eq!( + openai_models_url("https://open.bigmodel.cn/api/paas/v4/chat/completions"), + "https://open.bigmodel.cn/api/paas/v4/models" + ); + assert_eq!( + openai_models_url("https://api.openai.com"), + "https://api.openai.com/v1/models" + ); + assert_eq!( + openai_models_url("https://api.deepseek.com/anthropic"), + "https://api.deepseek.com/models" + ); + assert_eq!( + openai_models_url("http://localhost:11434"), + "http://localhost:11434/v1/models" + ); + assert_eq!( + openai_models_url("http://localhost:11434/v1"), + "http://localhost:11434/v1/models" + ); + } + + #[test] + fn test_openai_compatible_api_key_can_be_empty_for_local_providers() { + assert!(normalize_api_key("").is_err()); + assert_eq!(normalize_optional_api_key("").unwrap(), ""); + assert_eq!(normalize_optional_api_key(" ollama ").unwrap(), "ollama"); + assert!(normalize_optional_api_key("bad key").is_err()); + } + + #[test] + fn test_openai_compatible_verification_body_uses_chat_messages_without_tools() { + let body = openai_compatible_verification_body("qwen3-coder-plus"); + + assert_eq!(body["model"], "qwen3-coder-plus"); + assert_eq!(body["stream"], false); + assert_eq!(body["messages"][0]["role"], "user"); + assert!(body.get("tools").is_none()); + assert!(body.get("functions").is_none()); + } + + #[test] + fn test_openai_compatible_verification_error_is_actionable() { + let unauthorized = openai_compatible_verification_error( + reqwest::StatusCode::UNAUTHORIZED, + r#"{ "error": { "message": "bad key" } }"#, + ); + assert!(unauthorized.contains("Invalid provider API key")); + assert!(unauthorized.contains("bad key")); + + let not_found = + openai_compatible_verification_error(reqwest::StatusCode::NOT_FOUND, "no model"); + assert!(not_found.contains("Base URL and model name")); + + let rate_limited = + openai_compatible_verification_error(reqwest::StatusCode::TOO_MANY_REQUESTS, ""); + assert!(rate_limited.contains("rate limited")); + } + // --- create_command --- #[test] fn test_create_command_sets_args_and_cwd() { let args = vec!["--version".to_string()]; let cmd = create_command("/usr/bin/claude", args, "/tmp/project", None); - // Command is created — we can verify via its Debug representation + // Command is created 鈥?we can verify via its Debug representation let debug_str = format!("{:?}", cmd); assert!(debug_str.contains("--version")); } @@ -2115,8 +4981,7 @@ mod tests { #[test] fn test_clean_user_message_title_multibyte_truncation() { - // Truncation counts chars, not bytes - let text = "あ".repeat(100); // 100 Japanese chars + let text = "a".repeat(100); let result = clean_user_message_title(&text).unwrap(); assert!(result.ends_with("...")); // 77 chars + "..." = 80 display chars @@ -2136,7 +5001,7 @@ mod tests { fn test_get_sessions_dir_dots_and_underscores() { let result = get_sessions_dir("/home/user/.my_project.v2").unwrap(); let dir_name = result.file_name().unwrap().to_str().unwrap(); - // dots and underscores are non-alphanumeric → replaced with '-' + // dots and underscores are non-alphanumeric 鈫?replaced with '-' assert_eq!(dir_name, "-home-user--my-project-v2"); } @@ -2217,6 +5082,45 @@ mod tests { assert_eq!(ts.unwrap(), "2024-01-02T00:00:00Z"); } + #[test] + fn test_sanitize_model_session_title_strips_wrappers() { + let title = sanitize_model_session_title("`Title: FastVID Paper Revision`\nignored") + .expect("title should be accepted"); + assert_eq!(title, "FastVID Paper Revision"); + } + + #[test] + fn test_session_title_cache_survives_session_modification() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + std::fs::write(&path, "").unwrap(); + + write_session_title_cache(&path, 10, "Aftershock Modeling").unwrap(); + assert_eq!( + read_session_title_cache(&path).unwrap(), + "Aftershock Modeling" + ); + assert_eq!( + read_session_title_cache(&path).unwrap(), + "Aftershock Modeling" + ); + } + + #[test] + fn test_session_excerpt_for_model_title_collects_displayable_turns() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + let lines = r#"{"type":"user","message":{"content":"skip"}} +{"type":"user","message":{"content":[{"type":"text","text":"Please rewrite the FlashVID related work"}]}} +{"type":"assistant","message":{"content":[{"type":"text","text":"I will compare FlashVID with FastVID."}]}}"#; + std::fs::write(&path, lines).unwrap(); + + let excerpt = session_excerpt_for_model_title(&path).expect("excerpt should exist"); + assert!(!excerpt.contains("")); + assert!(excerpt.contains("User: Please rewrite")); + assert!(excerpt.contains("Assistant: I will compare")); + } + // --- claude_required_dirs --- #[cfg(not(target_os = "windows"))] @@ -2234,13 +5138,17 @@ mod tests { #[test] fn test_unix_claude_candidate_paths_include_pnpm_locations() { let home = PathBuf::from("/Users/test"); - let paths = unix_claude_candidate_paths( - &home, - Some(std::ffi::OsString::from("/custom/pnpm")), - ); + let paths = + unix_claude_candidate_paths(&home, Some(std::ffi::OsString::from("/custom/pnpm"))); assert!(paths.contains(&PathBuf::from("/custom/pnpm").join("claude"))); assert!(paths.contains(&home.join("Library").join("pnpm").join("claude"))); - assert!(paths.contains(&home.join(".local").join("share").join("pnpm").join("claude"))); + assert!(paths.contains( + &home + .join(".local") + .join("share") + .join("pnpm") + .join("claude") + )); assert!(paths.contains(&home.join(".pnpm").join("claude"))); assert!(paths.contains(&home.join(".claude").join("local").join("claude"))); } @@ -2274,7 +5182,13 @@ mod tests { .join("bin") .join("claude") )); - assert!(paths.contains(&home.join(".local").join("share").join("pnpm").join("claude"))); + assert!(paths.contains( + &home + .join(".local") + .join("share") + .join("pnpm") + .join("claude") + )); assert!(paths.contains( &home .join(".local") @@ -2285,9 +5199,7 @@ mod tests { .join("claude") )); assert!(paths.contains(&home.join(".pnpm").join("claude"))); - assert!(paths.contains( - &home.join(".pnpm").join("global").join("bin").join("claude") - )); + assert!(paths.contains(&home.join(".pnpm").join("global").join("bin").join("claude"))); } #[test] diff --git a/apps/desktop/src-tauri/src/claude_process.rs b/apps/desktop/src-tauri/src/claude_process.rs new file mode 100644 index 0000000..89700bc --- /dev/null +++ b/apps/desktop/src-tauri/src/claude_process.rs @@ -0,0 +1,368 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tauri::{Emitter, Manager, WebviewWindow}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[derive(Clone)] +pub struct ClaudeProcessState { + pub processes: Arc>>, +} + +impl Default for ClaudeProcessState { + fn default() -> Self { + Self { + processes: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +#[derive(Clone, serde::Serialize)] +struct ClaudeOutputEvent { + tab_id: String, + data: String, +} + +#[derive(Clone, serde::Serialize)] +struct ClaudeCompleteEvent { + tab_id: String, + success: bool, +} + +#[derive(Clone, serde::Serialize)] +struct ClaudeErrorEvent { + tab_id: String, + data: String, +} + +#[derive(Clone)] +pub struct SpawnProviderMetadata { + pub provider: &'static str, + pub provider_credential_id: String, + pub model: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClaudeStopMode { + /// User pressed Stop; terminate the run immediately. + Terminate, + /// User wants to guide the next turn; prefer a graceful interrupt so + /// Claude Code can persist session state before the frontend resumes it. + Interrupt, +} + +fn process_key(window_label: &str, tab_id: &str) -> String { + format!("{}:{}", window_label, tab_id) +} + +/// Spawn the Claude CLI process and stream output via Tauri events. +/// Events are emitted only to the originating window, tagged with tab_id. +pub async fn spawn_claude_process( + window: WebviewWindow, + mut cmd: Command, + tab_id: String, + stdin_payload: Option, + provider_metadata: Option, +) -> Result<(), String> { + let window_label = window.label().to_string(); + let process_key = process_key(&window_label, &tab_id); + + if stdin_payload.is_some() { + cmd.stdin(std::process::Stdio::piped()); + } + + let mut child = cmd.spawn().map_err(|e| { + eprintln!( + "[claude-spawn] Failed to spawn process for tab {}: {}", + tab_id, e + ); + format!( + "Failed to spawn Claude process: {}. Is Claude Code CLI installed?", + e + ) + })?; + + if let Some(payload) = stdin_payload { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "Failed to acquire stdin for Claude process".to_string())?; + stdin + .write_all(payload.as_bytes()) + .await + .map_err(|e| format!("Failed to write prompt to Claude process stdin: {}", e))?; + stdin + .shutdown() + .await + .map_err(|e| format!("Failed to close Claude process stdin: {}", e))?; + } + + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; + let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; + + let process_arc = window + .state::() + .inner() + .processes + .clone(); + + { + let mut processes = process_arc.lock().await; + if let Some(mut existing) = processes.remove(&process_key) { + let _ = existing.kill().await; + } + processes.insert(process_key.clone(), child); + } + + let stdout_reader = BufReader::new(stdout); + let stderr_reader = BufReader::new(stderr); + let result_success_holder: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + + let start_time = std::time::Instant::now(); + + let win_stdout = window.clone(); + let result_success_stdout = result_success_holder.clone(); + let tab_id_stdout = tab_id.clone(); + let provider_metadata_stdout = provider_metadata.clone(); + let stdout_task = tokio::spawn(async move { + let mut lines = stdout_reader.lines(); + let mut line_count: u64 = 0; + while let Ok(Some(mut line)) = lines.next_line().await { + line_count += 1; + let elapsed = start_time.elapsed().as_secs_f64(); + + if let Ok(mut msg) = serde_json::from_str::(&line) { + let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("?"); + let msg_sub = msg.get("subtype").and_then(|v| v.as_str()).unwrap_or(""); + eprintln!( + "[claude-stdout] [{}] +{:.1}s #{} type={} sub={} len={}", + tab_id_stdout, + elapsed, + line_count, + msg_type, + msg_sub, + line.len() + ); + + if msg.get("type").and_then(|v| v.as_str()) == Some("system") + && msg.get("subtype").and_then(|v| v.as_str()) == Some("init") + { + if let Some(metadata) = provider_metadata_stdout.as_ref() { + if let Some(object) = msg.as_object_mut() { + object.insert( + "provider".to_string(), + serde_json::Value::String(metadata.provider.to_string()), + ); + object.insert( + "provider_credential_id".to_string(), + serde_json::Value::String(metadata.provider_credential_id.clone()), + ); + object.insert( + "model".to_string(), + serde_json::Value::String(metadata.model.clone()), + ); + } + line = msg.to_string(); + } + } + + if msg.get("type").and_then(|v| v.as_str()) == Some("result") { + let is_success = msg.get("subtype").and_then(|v| v.as_str()) == Some("success"); + if let Ok(mut guard) = result_success_stdout.lock() { + *guard = Some(is_success); + } + } + } + + let _ = win_stdout.emit( + "claude-output", + ClaudeOutputEvent { + tab_id: tab_id_stdout.clone(), + data: line, + }, + ); + } + eprintln!( + "[claude-stdout] [{}] stream ended after {} lines ({:.1}s)", + tab_id_stdout, + line_count, + start_time.elapsed().as_secs_f64() + ); + }); + + let win_stderr = window.clone(); + let tab_id_stderr = tab_id.clone(); + let stderr_task = tokio::spawn(async move { + let mut lines = stderr_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!( + "[claude-stderr] [{}] +{:.1}s {}", + tab_id_stderr, + start_time.elapsed().as_secs_f64(), + &line[..line.len().min(200)] + ); + let _ = win_stderr.emit( + "claude-error", + ClaudeErrorEvent { + tab_id: tab_id_stderr.clone(), + data: line, + }, + ); + } + }); + + let process_arc_wait = process_arc.clone(); + let win_wait = window; + let process_key_wait = process_key; + let tab_id_wait = tab_id; + let result_success_wait = result_success_holder.clone(); + tokio::spawn(async move { + let _ = stdout_task.await; + let _ = stderr_task.await; + + let mut processes = process_arc_wait.lock().await; + let success = if let Some(mut child) = processes.remove(&process_key_wait) { + match child.wait().await { + Ok(status) => { + let exit_success = status.success(); + let result_success = result_success_wait.lock().ok().and_then(|guard| *guard); + let success = exit_success || result_success == Some(true); + eprintln!( + "[claude-process] [{}] exited with status={} result_success={:?} final_success={} ({:.1}s)", + tab_id_wait, + status, + result_success, + success, + start_time.elapsed().as_secs_f64() + ); + success + } + Err(e) => { + eprintln!( + "[claude-process] [{}] wait error: {} ({:.1}s)", + tab_id_wait, + e, + start_time.elapsed().as_secs_f64() + ); + false + } + } + } else { + eprintln!( + "[claude-process] [{}] no child found in map ({:.1}s)", + tab_id_wait, + start_time.elapsed().as_secs_f64() + ); + false + }; + drop(processes); + + let _ = win_wait.emit( + "claude-complete", + ClaudeCompleteEvent { + tab_id: tab_id_wait, + success, + }, + ); + }); + + Ok(()) +} + +pub async fn stop_claude_process( + window: WebviewWindow, + tab_id: String, + mode: ClaudeStopMode, +) -> Result { + let window_label = window.label().to_string(); + let process_key = process_key(&window_label, &tab_id); + let claude_state = window.state::(); + let mut processes = claude_state.processes.lock().await; + if let Some(mut child) = processes.remove(&process_key) { + drop(processes); + let stopped = match mode { + ClaudeStopMode::Terminate => { + terminate_process_tree(&mut child).await; + true + } + ClaudeStopMode::Interrupt => interrupt_or_terminate(&mut child).await, + }; + return Ok(stopped); + } + drop(processes); + + let _ = window.emit( + "claude-complete", + ClaudeCompleteEvent { + tab_id, + success: false, + }, + ); + Ok(false) +} + +#[cfg(unix)] +async fn interrupt_or_terminate(child: &mut Child) -> bool { + if let Some(pid) = child.id() { + let status = tokio::process::Command::new("kill") + .arg("-INT") + .arg(pid.to_string()) + .status() + .await; + if matches!(status, Ok(status) if status.success()) { + return true; + } + } + terminate_process_tree(child).await; + true +} + +#[cfg(not(unix))] +async fn interrupt_or_terminate(child: &mut Child) -> bool { + // Windows GUI processes do not have a reliable console-control path from + // Tauri without a PTY/ConPTY session. For guided follow-ups, fall back to + // terminating the current run so the frontend can immediately continue the + // same tab with the queued guidance. + terminate_process_tree(child).await; + true +} + +#[cfg(windows)] +async fn terminate_process_tree(child: &mut Child) { + if let Some(pid) = child.id() { + let _ = Command::new("taskkill") + .creation_flags(CREATE_NO_WINDOW) + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + let _ = child.start_kill(); +} + +#[cfg(not(windows))] +async fn terminate_process_tree(child: &mut Child) { + let _ = child.start_kill(); +} + +/// Kill all Claude processes associated with a specific window label. +/// Called when a window is destroyed. +pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) { + let mut processes = state.processes.lock().await; + let prefix = format!("{}:", window_label); + let keys_to_remove: Vec = processes + .keys() + .filter(|k| k.starts_with(&prefix)) + .cloned() + .collect(); + for key in keys_to_remove { + if let Some(mut child) = processes.remove(&key) { + let _ = child.kill().await; + } + } +} diff --git a/apps/desktop/src-tauri/src/latex.rs b/apps/desktop/src-tauri/src/latex.rs index df1c317..da50029 100644 --- a/apps/desktop/src-tauri/src/latex.rs +++ b/apps/desktop/src-tauri/src/latex.rs @@ -258,8 +258,7 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> { let dst_path = dst.join(&file_name); if src_path.is_dir() { let name = file_name.to_string_lossy(); - if name.starts_with('.') - || matches!(name.as_ref(), "node_modules" | "target" | "dist") + if name.starts_with('.') || matches!(name.as_ref(), "node_modules" | "target" | "dist") { continue; } @@ -507,7 +506,11 @@ fn compile_with_texlive( let engine_path = find_texlive_binary(engine_name)?; let env_path = texlive_env_path(&engine_path); - eprintln!("[texlive] backend: {} ({})", engine_name, engine_path.display()); + eprintln!( + "[texlive] backend: {} ({})", + engine_name, + engine_path.display() + ); let bib_tool = detect_bib_tool(tex_content); // Use "." as output-directory since current_dir is already work_dir. @@ -518,11 +521,7 @@ fn compile_with_texlive( // With -halt-on-error, recoverable warnings (e.g. missing font shapes) cause xetex to // exit non-zero, and the xelatex wrapper skips the xdvipdfmx step — producing .xdv but // no .pdf. -interaction=nonstopmode alone is sufficient to avoid interactive prompts. - let common_args: Vec<&str> = vec![ - "-synctex=1", - "-interaction=nonstopmode", - &output_dir_arg, - ]; + let common_args: Vec<&str> = vec!["-synctex=1", "-interaction=nonstopmode", &output_dir_arg]; let main_file_path = Path::new(main_file); @@ -1108,7 +1107,8 @@ mod tests { #[test] fn test_detect_bib_tool_biber() { - let content = "\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}"; + let content = + "\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}"; assert_eq!(detect_bib_tool(content), BibTool::Biber); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d720552..7c7bb13 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,8 @@ +#![recursion_limit = "512"] + +mod anthropic_proxy; mod claude; +mod claude_process; mod history; mod latex; mod skills; @@ -7,8 +11,8 @@ mod uv; mod zotero; use std::path::Path; -use tauri_plugin_fs::FsExt; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri_plugin_fs::FsExt; /// Entry point for the `--tectonic-compile` subprocess mode. /// Runs tectonic compilation in an isolated process so that C-level global state @@ -183,6 +187,7 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> { .title("ClaudePrism") .inner_size(1400.0, 900.0) .min_inner_size(800.0, 600.0) + .zoom_hotkeys_enabled(true) .visible(false); #[cfg(target_os = "macos")] @@ -199,6 +204,124 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "windows")] +fn apply_windows_titlebar_theme(window: &tauri::WebviewWindow, dark: bool) -> Result<(), String> { + use std::ffi::c_void; + + #[link(name = "dwmapi")] + extern "system" { + #[link_name = "DwmSetWindowAttribute"] + fn dwm_set_window_attribute( + hwnd: isize, + dwattribute: u32, + pvattribute: *const c_void, + cbattribute: u32, + ) -> i32; + } + + #[link(name = "user32")] + extern "system" { + #[link_name = "SetWindowPos"] + fn set_window_pos( + hwnd: isize, + hwnd_insert_after: isize, + x: i32, + y: i32, + cx: i32, + cy: i32, + flags: u32, + ) -> i32; + } + + const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20; + const SWP_NOSIZE: u32 = 0x0001; + const SWP_NOMOVE: u32 = 0x0002; + const SWP_NOZORDER: u32 = 0x0004; + const SWP_NOACTIVATE: u32 = 0x0010; + const SWP_FRAMECHANGED: u32 = 0x0020; + + let hwnd = window + .hwnd() + .map_err(|e| format!("Failed to resolve native window handle: {}", e))?; + let hwnd = hwnd.0 as isize; + let dark_value: i32 = if dark { 1 } else { 0 }; + let attr_size = std::mem::size_of_val(&dark_value) as u32; + + let mut result = unsafe { + dwm_set_window_attribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + &dark_value as *const _ as *const _, + attr_size, + ) + }; + if result < 0 { + // Older Windows 10 builds used attribute 19 before Microsoft documented 20. + result = unsafe { + dwm_set_window_attribute(hwnd, 19, &dark_value as *const _ as *const _, attr_size) + }; + } + + // Windows 11 honors explicit caption/text colors more reliably than the + // immersive flag alone, especially after runtime theme switches. + const DWMWA_CAPTION_COLOR: u32 = 35; + const DWMWA_TEXT_COLOR: u32 = 36; + let caption_color: u32 = if dark { 0x0010_1010 } else { 0x00F9_F9F9 }; + let text_color: u32 = if dark { 0x00FF_FFFF } else { 0x0000_0000 }; + unsafe { + let _ = dwm_set_window_attribute( + hwnd, + DWMWA_CAPTION_COLOR, + &caption_color as *const _ as *const _, + std::mem::size_of_val(&caption_color) as u32, + ); + let _ = dwm_set_window_attribute( + hwnd, + DWMWA_TEXT_COLOR, + &text_color as *const _ as *const _, + std::mem::size_of_val(&text_color) as u32, + ); + let _ = set_window_pos( + hwnd, + 0, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ); + } + + if result < 0 { + return Err(format!( + "Failed to update Windows title bar theme: HRESULT 0x{:08X}", + result as u32 + )); + } + + Ok(()) +} + +#[tauri::command] +fn set_native_window_theme(window: tauri::WebviewWindow, theme: String) -> Result<(), String> { + let theme = theme.trim().to_ascii_lowercase(); + let dark = theme == "dark"; + let tauri_theme = if dark { + tauri::Theme::Dark + } else { + tauri::Theme::Light + }; + + window + .set_theme(Some(tauri_theme)) + .map_err(|e| format!("Failed to set window theme: {}", e))?; + + #[cfg(target_os = "windows")] + apply_windows_titlebar_theme(&window, dark)?; + + Ok(()) +} + #[tauri::command] fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<(), String> { let fs_scope = app.fs_scope(); @@ -214,6 +337,108 @@ fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<( Ok(()) } +#[derive(serde::Serialize)] +struct ProjectCandidate { + path: String, + name: String, + last_modified: u64, + has_main_tex: bool, +} + +fn modified_ms(path: &Path) -> u64 { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn has_tex_file(dir: &Path) -> bool { + if dir.join("main.tex").is_file() || dir.join("document.tex").is_file() { + return true; + } + + std::fs::read_dir(dir) + .ok() + .into_iter() + .flat_map(|entries| entries.flatten()) + .any(|entry| { + let path = entry.path(); + if !path.is_file() { + return false; + } + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref(), + Some("tex" | "ltx") + ) + }) +} + +fn project_modified_ms(dir: &Path) -> u64 { + let mut latest = modified_ms(dir); + for relative in [ + "main.tex", + "document.tex", + ".prism/build/main.pdf", + ".claudeprism/history.git/.git/refs/heads/master", + ] { + latest = latest.max(modified_ms(&dir.join(relative))); + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + latest = latest.max(modified_ms(&path)); + } + } + } + + latest +} + +#[tauri::command] +fn list_default_projects() -> Result, String> { + let Some(home) = dirs::home_dir() else { + return Ok(Vec::new()); + }; + + let base = home.join("Documents").join("ClaudePrism"); + if !base.is_dir() { + return Ok(Vec::new()); + } + + let mut projects = Vec::new(); + let entries = std::fs::read_dir(&base) + .map_err(|e| format!("Failed to read default project directory: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || !has_tex_file(&path) { + continue; + } + + projects.push(ProjectCandidate { + path: path.to_string_lossy().to_string(), + name, + last_modified: project_modified_ms(&path), + has_main_tex: path.join("main.tex").is_file() || path.join("document.tex").is_file(), + }); + } + + projects.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + Ok(projects) +} + // --- Debug logging from JS (survives white-screen crashes) --- #[tauri::command] @@ -236,6 +461,7 @@ fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> { .title("ClaudePrism — Debug") .inner_size(560.0, 700.0) .min_inner_size(400.0, 400.0) + .zoom_hotkeys_enabled(true) .visible(true) .build() .map_err(|e| format!("Failed to create debug window: {}", e))?; @@ -353,9 +579,7 @@ pub fn run() { tokio::time::sleep(std::time::Duration::from_secs(8)).await; if let Some(window) = handle.get_webview_window("main") { if !window.is_visible().unwrap_or(true) { - eprintln!( - "[safety] Main window still hidden after 8s, force-showing" - ); + eprintln!("[safety] Main window still hidden after 8s, force-showing"); let _ = window.show(); let _ = window.set_focus(); } @@ -365,7 +589,9 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ create_new_window, + set_native_window_theme, allow_project_directory, + list_default_projects, detect_editors, open_in_editor, js_log, @@ -376,15 +602,27 @@ pub fn run() { claude::check_claude_status, claude::install_claude_cli, claude::login_claude, + claude::save_anthropic_api_key, + claude::verify_openai_compatible_api_key, + claude::list_openai_compatible_models, + claude::list_openai_compatible_credential_models, + claude::clear_anthropic_api_key, + claude::list_openai_compatible_credentials, + claude::delete_openai_compatible_credential, + claude::set_active_openai_compatible_credential, claude::execute_claude_code, claude::continue_claude_code, claude::resume_claude_code, claude::cancel_claude_execution, + claude::interrupt_claude_execution, claude::run_shell_command, + claude::migrate_project_sessions, claude::get_claude_fast_mode, claude::set_claude_fast_mode, claude::list_claude_sessions, + claude::generate_claude_session_title, claude::load_session_history, + claude::delete_claude_session, zotero::zotero_start_oauth, zotero::zotero_complete_oauth, zotero::zotero_cancel_oauth, @@ -402,8 +640,10 @@ pub fn run() { slash_commands::slash_command_delete, skills::install_scientific_skills, skills::install_scientific_skills_global, + skills::import_skill_from_folder, skills::check_skills_installed, skills::list_installed_skills, + skills::delete_installed_skill, skills::uninstall_scientific_skills, skills::get_skill_categories, skills::get_skill_content, @@ -457,7 +697,7 @@ pub fn run() { let _ = window.eval( "document.body.style.display='none';\ document.body.offsetHeight;\ - document.body.style.display='';" + document.body.style.display='';", ); } let _ = window.emit("window-focus-restored", ()); diff --git a/apps/desktop/src-tauri/src/skills.rs b/apps/desktop/src-tauri/src/skills.rs index 7624249..aa7fae5 100644 --- a/apps/desktop/src-tauri/src/skills.rs +++ b/apps/desktop/src-tauri/src/skills.rs @@ -1,10 +1,23 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri::{Emitter, WebviewWindow}; +use std::time::Duration; +use tauri::{Emitter, Manager, WebviewWindow}; -const TARBALL_URL: &str = - "https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz"; -const SKILLS_SUBFOLDER: &str = "scientific-skills"; +const TARBALL_URLS: &[&str] = &[ + "https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz", + "https://codeload.github.com/K-Dense-AI/scientific-agent-skills/tar.gz/refs/heads/main", + "https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz", +]; +const SKILLS_DOWNLOAD_ATTEMPTS: usize = 3; +const SKILLS_DOWNLOAD_TIMEOUT_SECS: u64 = 240; +const SKILLS_CONNECT_TIMEOUT_SECS: u64 = 20; +const SKILLS_INSTALL_TIMEOUT_SECS: u64 = 420; +const SKILL_CONTENT_TIMEOUT_SECS: u64 = 45; +const RAW_SKILL_URLS: &[&str] = &[ + "https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/main/skills", + "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills", +]; +const SKILLS_SUBFOLDERS: &[&str] = &["skills", "scientific-skills"]; // ─── Data Types ─── @@ -357,82 +370,710 @@ fn skills_dir(project_path: Option<&str>) -> PathBuf { } } -/// Download and extract tarball. -async fn download_tarball(tmp_dir: &Path) -> Result<(), String> { - let response = reqwest::get(TARBALL_URL) +fn sanitize_skill_folder_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +fn find_skill_md(skill_dir: &Path) -> Option { + for name in ["SKILL.md", "skill.md"] { + let candidate = skill_dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + + let entries = std::fs::read_dir(skill_dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + { + return Some(path); + } + } + + None +} + +fn collect_skill_dirs(root: &Path, output: &mut Vec) { + if find_skill_md(root).is_some() { + output.push(root.to_path_buf()); + return; + } + + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + collect_skill_dirs(&entry.path(), output); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProxyKind { + All, + Http, + Https, +} + +struct ProxyRule { + kind: ProxyKind, + url: String, + source: String, +} + +fn first_env_value(names: &[&str]) -> Option<(String, String)> { + for name in names { + let Ok(value) = std::env::var(name) else { + continue; + }; + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(((*name).to_string(), trimmed.to_string())); + } + } + + None +} + +fn normalize_proxy_url(raw: &str) -> Option { + normalize_proxy_url_with_default(raw, "http") +} + +fn normalize_proxy_url_with_default(raw: &str, default_scheme: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.contains("://") { + Some(trimmed.to_string()) + } else { + Some(format!("{}://{}", default_scheme, trimmed)) + } +} + +fn explicit_env_proxy_rules() -> Vec { + let mut rules = Vec::new(); + let mut has_https_proxy = false; + let mut has_all_proxy = false; + let mut http_proxy = None; + + if let Some((source, raw)) = first_env_value(&["HTTPS_PROXY", "https_proxy"]) { + if let Some(url) = normalize_proxy_url(&raw) { + rules.push(ProxyRule { + kind: ProxyKind::Https, + url, + source, + }); + has_https_proxy = true; + } + } + + if let Some((source, raw)) = first_env_value(&["HTTP_PROXY", "http_proxy"]) { + if let Some(url) = normalize_proxy_url(&raw) { + http_proxy = Some((source.clone(), url.clone())); + rules.push(ProxyRule { + kind: ProxyKind::Http, + url, + source, + }); + } + } + + if let Some((source, raw)) = first_env_value(&["ALL_PROXY", "all_proxy"]) { + if let Some(url) = normalize_proxy_url(&raw) { + rules.push(ProxyRule { + kind: ProxyKind::All, + url, + source, + }); + has_all_proxy = true; + } + } + + if !has_https_proxy && !has_all_proxy { + if let Some((source, url)) = http_proxy { + rules.insert( + 0, + ProxyRule { + kind: ProxyKind::Https, + url, + source: format!("{} (HTTPS fallback)", source), + }, + ); + } + } + + rules +} + +#[cfg(target_os = "windows")] +fn windows_proxy_override_to_no_proxy(raw: &str) -> Option { + let entries = raw + .split([';', ',']) + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.eq_ignore_ascii_case("") { + return Some("localhost,127.0.0.1,::1".to_string()); + } + + if trimmed == "*" { + return Some(trimmed.to_string()); + } + + if trimmed.contains('*') { + return trimmed + .strip_prefix("*.") + .map(|domain| format!(".{}", domain.trim_start_matches('.'))); + } + + Some(trimmed.to_string()) + }) + .collect::>(); + + if entries.is_empty() { + None + } else { + reqwest::NoProxy::from_string(&entries.join(",")) + } +} + +#[cfg(target_os = "windows")] +fn windows_system_no_proxy() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let settings = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + .ok()?; + let raw = settings.get_value::("ProxyOverride").ok()?; + windows_proxy_override_to_no_proxy(&raw) +} + +#[cfg(target_os = "windows")] +fn parse_windows_proxy_server(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + if !trimmed.contains('=') { + return normalize_proxy_url(trimmed) + .map(|url| { + vec![ProxyRule { + kind: ProxyKind::All, + url, + source: "Windows system proxy".to_string(), + }] + }) + .unwrap_or_default(); + } + + let mut rules = Vec::new(); + for entry in trimmed.split(';') { + let Some((scheme, value)) = entry.split_once('=') else { + continue; + }; + let scheme = scheme.trim(); + let (kind, default_proxy_scheme) = match scheme.to_ascii_lowercase().as_str() { + "http" => (ProxyKind::Http, "http"), + "https" => (ProxyKind::Https, "http"), + "socks" | "socks5" => (ProxyKind::All, "socks5"), + "socks4" => (ProxyKind::All, "socks4"), + _ => continue, + }; + let Some(url) = normalize_proxy_url_with_default(value, default_proxy_scheme) else { + continue; + }; + + rules.push(ProxyRule { + kind, + url, + source: format!("Windows system proxy ({})", scheme.trim()), + }); + } + + rules +} + +#[cfg(target_os = "windows")] +fn windows_system_proxy_rules() -> Vec { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + else { + return Vec::new(); + }; + + let proxy_enabled = settings.get_value::("ProxyEnable").unwrap_or(0) != 0; + if !proxy_enabled { + return Vec::new(); + } + + settings + .get_value::("ProxyServer") + .map(|raw| parse_windows_proxy_server(&raw)) + .unwrap_or_default() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_proxy_rules() -> Vec { + Vec::new() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_no_proxy() -> Option { + None +} + +fn redacted_proxy_url(url: &str) -> String { + let Ok(mut parsed) = reqwest::Url::parse(url) else { + return "".to_string(); + }; + + if !parsed.username().is_empty() { + let _ = parsed.set_username("***"); + if parsed.password().is_some() { + let _ = parsed.set_password(Some("***")); + } + } + + parsed.to_string() +} + +fn add_proxy_rule( + builder: reqwest::ClientBuilder, + rule: &ProxyRule, + no_proxy: Option, +) -> Result { + let proxy = match rule.kind { + ProxyKind::All => reqwest::Proxy::all(&rule.url), + ProxyKind::Http => reqwest::Proxy::http(&rule.url), + ProxyKind::Https => reqwest::Proxy::https(&rule.url), + } + .map_err(|e| { + format!( + "Invalid proxy from {} ({}): {}", + rule.source, + redacted_proxy_url(&rule.url), + e + ) + })?; + + let proxy = proxy.no_proxy(no_proxy); + Ok(builder.proxy(proxy)) +} + +fn configure_proxy_for_client( + mut builder: reqwest::ClientBuilder, + window: Option<&WebviewWindow>, +) -> Result { + let mut rules = explicit_env_proxy_rules(); + let mut no_proxy = reqwest::NoProxy::from_env(); + + if rules.is_empty() { + let windows_rules = windows_system_proxy_rules(); + if !windows_rules.is_empty() { + rules = windows_rules; + no_proxy = windows_system_no_proxy(); + } + } + + if rules.is_empty() { + if let Some(window) = window { + emit_log(window, "Using system proxy settings when available"); + } + return Ok(builder); + } + + if let Some(window) = window { + for rule in &rules { + emit_log( + window, + &format!( + "Using proxy from {}: {}", + rule.source, + redacted_proxy_url(&rule.url) + ), + ); + } + } + + for rule in &rules { + builder = add_proxy_rule(builder, rule, no_proxy.clone())?; + } + + Ok(builder) +} + +fn build_skills_http_client( + timeout_secs: u64, + window: Option<&WebviewWindow>, +) -> Result { + let builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(SKILLS_CONNECT_TIMEOUT_SECS)) + .timeout(Duration::from_secs(timeout_secs)); + + configure_proxy_for_client(builder, window)? + .build() + .map_err(|e| format!("Failed to create download client: {}", e)) +} + +fn tarball_source_label(url: &str) -> &'static str { + if url.contains("codeload.github.com") { + "GitHub codeload" + } else if url.contains("claude-scientific-skills") { + "legacy GitHub archive" + } else { + "GitHub archive" + } +} + +fn reset_download_workspace(tmp_dir: &Path) { + let _ = std::fs::remove_dir_all(tmp_dir.join("repo")); + let _ = std::fs::remove_dir_all(tmp_dir.join("repo-raw")); +} + +fn find_extracted_repo_dir(raw_dir: &Path) -> Option { + let mut candidates = Vec::new(); + let entries = std::fs::read_dir(raw_dir).ok()?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + candidates.push(path); + } + } + candidates.sort(); + + for candidate in &candidates { + if find_skills_source(candidate).is_some() { + return Some(candidate.clone()); + } + } + + candidates.into_iter().next() +} + +fn unpack_tarball(bytes: &[u8], tmp_dir: &Path) -> Result<(), String> { + reset_download_workspace(tmp_dir); + + let raw_dir = tmp_dir.join("repo-raw"); + std::fs::create_dir_all(&raw_dir) + .map_err(|e| format!("Failed to create extraction dir: {}", e))?; + + let decoder = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + + archive + .unpack(&raw_dir) + .map_err(|e| format!("Failed to extract tarball: {}", e))?; + + let repo_source = find_extracted_repo_dir(&raw_dir) + .ok_or_else(|| "Downloaded tarball did not contain a repository directory".to_string())?; + + std::fs::rename(&repo_source, tmp_dir.join("repo")) + .map_err(|e| format!("Failed to prepare extracted repo: {}", e))?; + + let _ = std::fs::remove_dir_all(&raw_dir); + Ok(()) +} + +async fn download_tarball_once( + client: &reqwest::Client, + window: &WebviewWindow, + tmp_dir: &Path, + url: &str, +) -> Result<(), String> { + reset_download_workspace(tmp_dir); + + let source_label = tarball_source_label(url); + emit_log(window, &format!("Downloading from {}...", source_label)); + + let mut response = client + .get(url) + .header(reqwest::header::USER_AGENT, "ClaudePrism skills installer") + .send() .await - .map_err(|e| format!("Failed to download tarball: {}", e))?; + .map_err(|e| format!("Failed to start download: {}", e))?; if !response.status().is_success() { return Err(format!( - "Tarball download failed with status: {}", + "Download failed with status: {}", response.status() )); } - let bytes = response - .bytes() + let total_size = response.content_length(); + let mut bytes = + Vec::with_capacity(total_size.unwrap_or_default().min(64 * 1024 * 1024) as usize); + let mut downloaded = 0_u64; + let mut last_emitted_percent = 0_u64; + + while let Some(chunk) = response + .chunk() .await - .map_err(|e| format!("Failed to read tarball bytes: {}", e))?; + .map_err(|e| format!("Failed to read download bytes: {}", e))? + { + downloaded += chunk.len() as u64; + bytes.extend_from_slice(&chunk); - // Decompress gzip - let decoder = flate2::read::GzDecoder::new(&bytes[..]); - let mut archive = tar::Archive::new(decoder); - - archive - .unpack(tmp_dir.join("repo-raw")) - .map_err(|e| format!("Failed to extract tarball: {}", e))?; - - // The tarball extracts to claude-scientific-skills-main/ - // We need to find it and rename to repo/ - let raw_dir = tmp_dir.join("repo-raw"); - if let Ok(mut entries) = std::fs::read_dir(&raw_dir) { - if let Some(Ok(entry)) = entries.next() { - std::fs::rename(entry.path(), tmp_dir.join("repo")) - .map_err(|e| format!("Failed to rename extracted dir: {}", e))?; + if let Some(total) = total_size { + if total > 0 { + let percent = ((downloaded.saturating_mul(100)) / total).min(100); + if percent >= last_emitted_percent + 5 || percent == 100 { + emit_log(window, &format!("Download progress {}%", percent)); + last_emitted_percent = percent; + } + } + } else if downloaded / (1024 * 1024) > last_emitted_percent { + last_emitted_percent = downloaded / (1024 * 1024); + emit_log(window, &format!("Downloaded {} MiB", last_emitted_percent)); } } - // Clean up the raw extraction directory - let _ = std::fs::remove_dir_all(&raw_dir); - - Ok(()) + unpack_tarball(&bytes, tmp_dir) } -/// Copy the scientific-skills directory from the cloned repo to the target. -fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { - let src = repo_dir.join(SKILLS_SUBFOLDER); - if !src.exists() { - return Err(format!( - "scientific-skills directory not found in cloned repo at {}", - src.display() - )); +/// Download and extract tarball. +async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> { + let client = build_skills_http_client(SKILLS_DOWNLOAD_TIMEOUT_SECS, Some(window))?; + + let mut last_error = None; + for attempt in 1..=SKILLS_DOWNLOAD_ATTEMPTS { + for url in TARBALL_URLS { + let label = tarball_source_label(url); + emit_log( + window, + &format!( + "Download attempt {}/{} ({})", + attempt, SKILLS_DOWNLOAD_ATTEMPTS, label + ), + ); + + match download_tarball_once(&client, window, tmp_dir, url).await { + Ok(()) => return Ok(()), + Err(e) => { + let message = format!("{} failed: {}", label, e); + emit_log(window, &message); + last_error = Some(message); + reset_download_workspace(tmp_dir); + } + } + } + + if attempt < SKILLS_DOWNLOAD_ATTEMPTS { + let delay_secs = attempt as u64 * 2; + emit_log(window, &format!("Retrying in {} seconds...", delay_secs)); + tokio::time::sleep(Duration::from_secs(delay_secs)).await; + } } + Err(format!( + "Failed to download skills after {} attempts. Last error: {}", + SKILLS_DOWNLOAD_ATTEMPTS, + last_error.unwrap_or_else(|| "unknown download error".to_string()) + )) +} + +fn contains_skill_dirs(path: &Path) -> bool { + let mut dirs = Vec::new(); + collect_skill_dirs(path, &mut dirs); + !dirs.is_empty() +} + +fn find_skills_source(repo_dir: &Path) -> Option { + for subfolder in SKILLS_SUBFOLDERS { + let candidate = repo_dir.join(subfolder); + if contains_skill_dirs(&candidate) { + return Some(candidate); + } + } + + if contains_skill_dirs(repo_dir) { + return Some(repo_dir.to_path_buf()); + } + + let entries = std::fs::read_dir(repo_dir).ok()?; + for entry in entries.flatten() { + let candidate = entry.path(); + if candidate.is_dir() && contains_skill_dirs(&candidate) { + return Some(candidate); + } + } + + None +} + +fn skills_staging_dir(target_dir: &Path) -> PathBuf { + let parent = target_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let target_name = target_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skills"); + + parent.join(format!( + ".{}-installing-{}", + target_name, + uuid::Uuid::new_v4().simple() + )) +} + +fn hidden_sibling_path(path: &Path, label: &str) -> PathBuf { + let parent = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skill"); + + parent.join(format!( + ".{}-{}-{}", + name, + label, + uuid::Uuid::new_v4().simple() + )) +} + +fn replace_dir_from_staging(staged: &Path, target: &Path) -> Result<(), String> { + let backup = hidden_sibling_path(target, "backup"); + let had_existing = target.exists(); + + if had_existing { + std::fs::rename(target, &backup).map_err(|e| { + format!( + "Failed to prepare replacement for {}: {}", + target.display(), + e + ) + })?; + } + + match std::fs::rename(staged, target) { + Ok(()) => { + if had_existing { + let _ = std::fs::remove_dir_all(&backup); + } + Ok(()) + } + Err(e) => { + let restore_error = if had_existing { + std::fs::rename(&backup, target) + .err() + .map(|restore| format!(" Restore also failed: {}", restore)) + } else { + None + }; + + Err(format!( + "Failed to install {}: {}{}", + target.display(), + e, + restore_error.unwrap_or_default() + )) + } + } +} + +/// Copy the skills directory from the downloaded repo to the target. +fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { + let src = find_skills_source(repo_dir).ok_or_else(|| { + format!( + "skills directory not found in downloaded repo at {}", + repo_dir.display() + ) + })?; + // Create target directory std::fs::create_dir_all(target_dir) .map_err(|e| format!("Failed to create target dir: {}", e))?; - let mut count = 0; + let mut skill_dirs = Vec::new(); + collect_skill_dirs(&src, &mut skill_dirs); + skill_dirs.sort(); - // Iterate through skill subdirectories - let entries = - std::fs::read_dir(&src).map_err(|e| format!("Failed to read skills dir: {}", e))?; - - for entry in entries.flatten() { - let entry_path = entry.path(); - if !entry_path.is_dir() { - continue; - } - - let skill_name = entry.file_name().to_string_lossy().to_string(); - - let target_skill = target_dir.join(&skill_name); - copy_dir_recursive(&entry_path, &target_skill)?; - count += 1; + if skill_dirs.is_empty() { + return Err("No skills found in downloaded repository".into()); } - Ok(count) + let staging_dir = skills_staging_dir(target_dir); + std::fs::create_dir_all(&staging_dir) + .map_err(|e| format!("Failed to create staging dir: {}", e))?; + + let mut staged_names = Vec::new(); + let stage_result = (|| -> Result<(), String> { + for entry_path in &skill_dirs { + let Some(skill_name) = entry_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + let staged_skill = staging_dir.join(skill_name); + copy_dir_recursive(entry_path, &staged_skill)?; + staged_names.push(skill_name.to_string()); + } + + Ok(()) + })(); + + if let Err(e) = stage_result { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(e); + } + + let replace_result = (|| -> Result { + let mut count = 0; + for skill_name in staged_names { + let staged_skill = staging_dir.join(&skill_name); + let target_skill = target_dir.join(&skill_name); + + replace_dir_from_staging(&staged_skill, &target_skill).map_err(|e| { + format!( + "Failed to replace {} with staged skill {}: {}", + target_skill.display(), + skill_name, + e + ) + })?; + count += 1; + } + + Ok(count) + })(); + + let _ = std::fs::remove_dir_all(&staging_dir); + replace_result } /// Recursively copy a directory. @@ -460,10 +1101,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { /// Parse a SKILL.md file to extract skill info. fn parse_skill_md(skill_dir: &Path) -> Option { - let skill_md = skill_dir.join("SKILL.md"); - if !skill_md.exists() { - return None; - } + let skill_md = find_skill_md(skill_dir)?; let content = std::fs::read_to_string(&skill_md).ok()?; let folder = skill_dir.file_name()?.to_string_lossy().to_string(); @@ -508,7 +1146,7 @@ pub async fn install_scientific_skills( project_path: String, ) -> Result { let target = skills_dir(Some(&project_path)); - install_skills_to(&window, &target, Some(&project_path)).await + install_skills_with_timeout(&window, &target, Some(&project_path)).await } #[tauri::command] @@ -516,7 +1154,80 @@ pub async fn install_scientific_skills_global( window: WebviewWindow, ) -> Result { let target = skills_dir(None); - install_skills_to(&window, &target, None).await + install_skills_with_timeout(&window, &target, None).await +} + +#[tauri::command] +pub async fn import_skill_from_folder(source_path: String) -> Result, String> { + let source = PathBuf::from(&source_path); + if !source.is_dir() { + return Err("Selected path is not a folder".into()); + } + + let mut skill_dirs = Vec::new(); + collect_skill_dirs(&source, &mut skill_dirs); + skill_dirs.sort(); + + if skill_dirs.is_empty() { + return Err( + "Selected folder does not contain any Claude skills. A skill must contain SKILL.md." + .into(), + ); + } + + let target_root = skills_dir(None); + std::fs::create_dir_all(&target_root).map_err(|e| { + format!( + "Failed to create skills dir {}: {}", + target_root.display(), + e + ) + })?; + + let mut imported = Vec::new(); + for skill_dir in skill_dirs { + let raw_folder_name = skill_dir + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Selected skill folder has an invalid name".to_string())?; + let folder_name = sanitize_skill_folder_name(raw_folder_name); + if folder_name.is_empty() { + return Err("Selected skill folder has an invalid name".into()); + } + + let target = target_root.join(folder_name); + let source_canon = skill_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve selected skill folder: {}", e))?; + + if target.exists() { + let target_canon = target + .canonicalize() + .map_err(|e| format!("Failed to resolve existing skill folder: {}", e))?; + if target_canon == source_canon { + let info = parse_skill_md(&target).ok_or_else(|| { + "Selected skill folder has an unreadable SKILL.md".to_string() + })?; + imported.push(info); + continue; + } + + std::fs::remove_dir_all(&target).map_err(|e| { + format!( + "Failed to replace existing skill {}: {}", + target.display(), + e + ) + })?; + } + + copy_dir_recursive(&skill_dir, &target)?; + let info = parse_skill_md(&target) + .ok_or_else(|| "Imported skill has an unreadable SKILL.md".to_string())?; + imported.push(info); + } + + Ok(imported) } /// Ensure the target directory is creatable and writable. @@ -585,7 +1296,32 @@ fn ensure_target_writable(target: &Path) -> Result<(), String> { /// Emit a progress log event to the frontend + stderr for terminal debugging. fn emit_log(window: &WebviewWindow, msg: &str) { eprintln!("[skills] {}", msg); - let _ = window.emit("skills-install-log", msg); + let _ = window + .app_handle() + .emit("skills-install-log", msg.to_string()); +} + +async fn install_skills_with_timeout( + window: &WebviewWindow, + target: &Path, + project_path: Option<&str>, +) -> Result { + match tokio::time::timeout( + std::time::Duration::from_secs(SKILLS_INSTALL_TIMEOUT_SECS), + install_skills_to(window, target, project_path), + ) + .await + { + Ok(result) => result, + Err(_) => { + let message = format!( + "Skills installation timed out after {} seconds. Check your network or try again later.", + SKILLS_INSTALL_TIMEOUT_SECS + ); + emit_log(window, &message); + Err(message) + } + } } /// Core installation logic. @@ -606,7 +1342,7 @@ async fn install_skills_to( // Create a temporary directory for the clone/download let tmp_dir = std::env::temp_dir().join(format!( - "claude-scientific-skills-{}", + "scientific-agent-skills-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -618,36 +1354,43 @@ async fn install_skills_to( msg })?; - // Download via tarball (faster, no git/git-lfs dependency) - emit_log(window, "Downloading skills..."); - download_tarball(&tmp_dir).await.map_err(|e| { - emit_log(window, &format!("Download failed: {}", e)); - e - })?; - emit_log(window, "Download complete"); + let result = async { + // Download via tarball (faster, no git/git-lfs dependency) + emit_log(window, "Downloading skills..."); + download_tarball(window, &tmp_dir).await.map_err(|e| { + emit_log(window, &format!("Download failed: {}", e)); + e + })?; + emit_log(window, "Download complete"); - let repo_dir = tmp_dir.join("repo"); + let repo_dir = tmp_dir.join("repo"); - // Copy skills to target directory - emit_log(window, "Copying skills..."); - let count = copy_skills(&repo_dir, target).map_err(|e| { - emit_log(window, &format!("Copy failed: {}", e)); - e - })?; - emit_log(window, &format!("Copied {} skills", count)); + // Copy skills to target directory + emit_log(window, "Copying skills..."); + let count = copy_skills(&repo_dir, target).map_err(|e| { + emit_log(window, &format!("Copy failed: {}", e)); + e + })?; + emit_log(window, &format!("Copied {} skills", count)); - // Clean up temp directory - let _ = std::fs::remove_dir_all(&tmp_dir); - emit_log(window, "Cleanup complete"); + let target_str = target.to_string_lossy().to_string(); - let target_str = target.to_string_lossy().to_string(); + Ok(InstallResult { + success: true, + skills_installed: count, + target_dir: target_str.clone(), + message: format!("Successfully installed {} skills to {}", count, target_str), + }) + } + .await; - Ok(InstallResult { - success: true, - skills_installed: count, - target_dir: target_str.clone(), - message: format!("Successfully installed {} skills to {}", count, target_str), - }) + match std::fs::remove_dir_all(&tmp_dir) { + Ok(_) => emit_log(window, "Cleanup complete"), + Err(e) if tmp_dir.exists() => emit_log(window, &format!("Cleanup failed: {}", e)), + Err(_) => {} + } + + result } #[tauri::command] @@ -662,12 +1405,9 @@ pub async fn check_skills_installed(project_path: Option) -> Result 0, @@ -685,14 +1425,13 @@ pub async fn list_installed_skills(project_path: Option) -> Result) -> Result Result<(), String> { + if skill_folder.trim().is_empty() { + return Err("Skill folder cannot be empty".into()); + } + + let target = skills_dir(None); + if !target.exists() { + return Err("No global skills directory found".into()); + } + + let target_canon = target + .canonicalize() + .map_err(|e| format!("Failed to resolve global skills directory: {}", e))?; + + let mut skill_dirs = Vec::new(); + collect_skill_dirs(&target, &mut skill_dirs); + let skill_dir = skill_dirs + .into_iter() + .find(|dir| { + dir.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == skill_folder) + }) + .ok_or_else(|| format!("Skill '{}' is not installed", skill_folder))?; + + let skill_canon = skill_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve skill folder: {}", e))?; + if !skill_canon.starts_with(&target_canon) { + return Err("Refusing to delete a skill outside ~/.claude/skills".into()); + } + + std::fs::remove_dir_all(&skill_canon) + .map_err(|e| format!("Failed to delete skill {}: {}", skill_folder, e))?; + + Ok(()) +} + #[tauri::command] pub async fn uninstall_scientific_skills(project_path: Option) -> Result<(), String> { let target = skills_dir(project_path.as_deref()); @@ -730,35 +1508,69 @@ pub async fn get_skill_content( }; for base in &locations { - let skill_md = base.join(&skill_folder).join("SKILL.md"); - if skill_md.exists() { + let skill_dir = base.join(&skill_folder); + if let Some(skill_md) = find_skill_md(&skill_dir) { return std::fs::read_to_string(&skill_md) .map_err(|e| format!("Failed to read SKILL.md: {}", e)); } + + let mut skill_dirs = Vec::new(); + collect_skill_dirs(base, &mut skill_dirs); + for skill_dir in skill_dirs { + if skill_dir + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == skill_folder) + { + if let Some(skill_md) = find_skill_md(&skill_dir) { + return std::fs::read_to_string(&skill_md) + .map_err(|e| format!("Failed to read SKILL.md: {}", e)); + } + } + } } - // Fallback: fetch from GitHub - let url = format!( - "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills/{}/SKILL.md", - skill_folder - ); + // Fallback: fetch from GitHub. The upstream project moved from + // claude-scientific-skills/scientific-skills to scientific-agent-skills/skills. + let client = build_skills_http_client(SKILL_CONTENT_TIMEOUT_SECS, None) + .map_err(|e| format!("Failed to create GitHub client: {}", e))?; - let response = reqwest::get(&url) - .await - .map_err(|e| format!("Failed to fetch from GitHub: {}", e))?; + let mut last_error = None; + for base_url in RAW_SKILL_URLS { + for skill_file in ["SKILL.md", "skill.md"] { + let url = format!("{}/{}/{}", base_url, skill_folder, skill_file); + let response = match client + .get(&url) + .header(reqwest::header::USER_AGENT, "ClaudePrism skills viewer") + .send() + .await + { + Ok(response) => response, + Err(e) => { + last_error = Some(format!("{}: {}", url, e)); + continue; + } + }; - if !response.status().is_success() { - return Err(format!( - "Skill '{}' not found (HTTP {})", - skill_folder, - response.status() - )); + if response.status().is_success() { + match response.text().await { + Ok(text) => return Ok(text), + Err(e) => { + last_error = Some(format!("{}: failed to read response: {}", url, e)); + continue; + } + } + } + + last_error = Some(format!("{}: HTTP {}", url, response.status())); + } } - response - .text() - .await - .map_err(|e| format!("Failed to read response: {}", e)) + Err(format!( + "Skill '{}' not found. Last error: {}", + skill_folder, + last_error.unwrap_or_else(|| "unknown".to_string()) + )) } // ─── Tests ─── @@ -780,6 +1592,55 @@ mod tests { assert_eq!(dir, PathBuf::from("/tmp/my-project/.claude/skills")); } + #[test] + fn test_sanitize_skill_folder_name() { + assert_eq!( + sanitize_skill_folder_name("My Local Skill!"), + "my-local-skill" + ); + assert_eq!( + sanitize_skill_folder_name("__Data_Skill-01__"), + "__data_skill-01__" + ); + } + + #[test] + fn test_normalize_proxy_url_defaults_to_http() { + assert_eq!( + normalize_proxy_url("127.0.0.1:7890"), + Some("http://127.0.0.1:7890".to_string()) + ); + assert_eq!( + normalize_proxy_url("socks5://127.0.0.1:7891"), + Some("socks5://127.0.0.1:7891".to_string()) + ); + assert_eq!(normalize_proxy_url(" "), None); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_single_proxy() { + let rules = parse_windows_proxy_server("127.0.0.1:7890"); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].kind, ProxyKind::All); + assert_eq!(rules[0].url, "http://127.0.0.1:7890"); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_per_scheme_proxy() { + let rules = parse_windows_proxy_server( + "http=127.0.0.1:7890;https=127.0.0.1:7890;socks=127.0.0.1:7891", + ); + assert_eq!(rules.len(), 3); + assert_eq!(rules[0].kind, ProxyKind::Http); + assert_eq!(rules[0].url, "http://127.0.0.1:7890"); + assert_eq!(rules[1].kind, ProxyKind::Https); + assert_eq!(rules[1].url, "http://127.0.0.1:7890"); + assert_eq!(rules[2].kind, ProxyKind::All); + assert_eq!(rules[2].url, "socks5://127.0.0.1:7891"); + } + #[test] fn test_skill_categories_count() { let cats = skill_categories(); @@ -792,6 +1653,37 @@ mod tests { assert!(total >= 100); } + #[test] + fn test_find_skills_source_new_repo_layout() { + let tmp = tempfile::tempdir().unwrap(); + let skill_dir = tmp.path().join("skills").join("exploratory-data-analysis"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Exploratory Data Analysis").unwrap(); + + let src = find_skills_source(tmp.path()).unwrap(); + assert_eq!( + src.file_name().and_then(|name| name.to_str()), + Some("skills") + ); + } + + #[test] + fn test_find_skills_source_legacy_repo_layout() { + let tmp = tempfile::tempdir().unwrap(); + let skill_dir = tmp + .path() + .join("scientific-skills") + .join("exploratory-data-analysis"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Exploratory Data Analysis").unwrap(); + + let src = find_skills_source(tmp.path()).unwrap(); + assert_eq!( + src.file_name().and_then(|name| name.to_str()), + Some("scientific-skills") + ); + } + #[test] fn test_parse_skill_md() { let tmp = std::env::temp_dir().join("test-skill-parse"); diff --git a/apps/desktop/src-tauri/src/slash_commands.rs b/apps/desktop/src-tauri/src/slash_commands.rs index 3da5914..89dfea3 100644 --- a/apps/desktop/src-tauri/src/slash_commands.rs +++ b/apps/desktop/src-tauri/src/slash_commands.rs @@ -200,23 +200,16 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec { return Vec::new(); } - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return Vec::new(), - }; - + let mut skill_dirs = Vec::new(); + collect_skill_dirs(dir, &mut skill_dirs); + skill_dirs.sort(); let mut skills = Vec::new(); + let mut seen_ids = std::collections::HashSet::new(); - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { + for path in skill_dirs { + let Some(skill_md) = find_skill_md(&path) else { continue; - } - - let skill_md = path.join("SKILL.md"); - if !skill_md.exists() { - continue; - } + }; let content = match fs::read_to_string(&skill_md) { Ok(c) => c, @@ -248,6 +241,9 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec { .map(|d| d.chars().take(200).collect()); let id = format!("skill-{}", folder_name); + if !seen_ids.insert(id.clone()) { + continue; + } skills.push(SlashCommand { id, @@ -269,6 +265,51 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec { skills } +fn find_skill_md(skill_dir: &Path) -> Option { + for name in ["SKILL.md", "skill.md"] { + let candidate = skill_dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + + let entries = fs::read_dir(skill_dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + { + return Some(path); + } + } + + None +} + +fn collect_skill_dirs(root: &Path, output: &mut Vec) { + if find_skill_md(root).is_some() { + output.push(root.to_path_buf()); + return; + } + + let Ok(entries) = fs::read_dir(root) else { + return; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + collect_skill_dirs(&entry.path(), output); + } +} + fn create_default_commands() -> Vec { vec![ SlashCommand { diff --git a/apps/desktop/src-tauri/src/uv.rs b/apps/desktop/src-tauri/src/uv.rs index 7edf934..5442d6f 100644 --- a/apps/desktop/src-tauri/src/uv.rs +++ b/apps/desktop/src-tauri/src/uv.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tauri::{Emitter, WebviewWindow}; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -113,6 +113,28 @@ fn venv_python(venv_dir: &std::path::Path) -> PathBuf { } } +fn venv_pip(venv_dir: &std::path::Path) -> PathBuf { + #[cfg(not(target_os = "windows"))] + { + venv_bin_dir(venv_dir).join("pip") + } + #[cfg(target_os = "windows")] + { + venv_bin_dir(venv_dir).join("pip.exe") + } +} + +fn venv_pip_shim(venv_dir: &std::path::Path) -> PathBuf { + #[cfg(not(target_os = "windows"))] + { + venv_bin_dir(venv_dir).join("pip") + } + #[cfg(target_os = "windows")] + { + venv_bin_dir(venv_dir).join("pip.cmd") + } +} + fn path_with_venv(venv_dir: &std::path::Path) -> String { let bin = venv_bin_dir(venv_dir); let current = std::env::var("PATH").unwrap_or_default(); @@ -123,6 +145,73 @@ fn path_with_venv(venv_dir: &std::path::Path) -> String { format!("{}{}{}", bin.to_string_lossy(), sep, current) } +fn write_pip_shim(venv_dir: &Path) -> Result<(), String> { + let uv_bin = find_uv_binary().unwrap_or_else(|_| "uv".to_string()); + let shim_path = venv_pip_shim(venv_dir); + + #[cfg(target_os = "windows")] + { + let content = format!( + "@echo off\r\nset \"VIRTUAL_ENV={}\"\r\n\"{}\" pip %*\r\n", + venv_dir.to_string_lossy(), + uv_bin + ); + std::fs::write(&shim_path, &content) + .map_err(|e| format!("Failed to create pip shim: {}", e))?; + let pip3_path = venv_bin_dir(venv_dir).join("pip3.cmd"); + let _ = std::fs::write(pip3_path, content); + } + + #[cfg(not(target_os = "windows"))] + { + let content = format!( + "#!/bin/sh\nVIRTUAL_ENV=\"{}\" exec \"{}\" pip \"$@\"\n", + venv_dir.to_string_lossy(), + uv_bin + ); + std::fs::write(&shim_path, content) + .map_err(|e| format!("Failed to create pip shim: {}", e))?; + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&shim_path) + .map_err(|e| format!("Failed to stat pip shim: {}", e))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&shim_path, perms) + .map_err(|e| format!("Failed to mark pip shim executable: {}", e))?; + } + + Ok(()) +} + +async fn ensure_venv_pip(venv_dir: &Path) -> Result<(), String> { + if venv_pip(venv_dir).exists() || venv_pip_shim(venv_dir).exists() { + return Ok(()); + } + + let python = venv_python(venv_dir); + if !python.exists() { + return Err(format!( + "Project .venv is missing Python at {}", + python.display() + )); + } + + let mut ensure_cmd = tokio::process::Command::new(&python); + ensure_cmd.args(["-m", "ensurepip", "--upgrade"]); + ensure_cmd.env("VIRTUAL_ENV", venv_dir); + ensure_cmd.env("PATH", path_with_venv(venv_dir)); + ensure_cmd.env("PYTHONNOUSERSITE", "1"); + #[cfg(target_os = "windows")] + { + ensure_cmd.creation_flags(CREATE_NO_WINDOW); + } + + match ensure_cmd.output().await { + Ok(output) if output.status.success() && venv_pip(venv_dir).exists() => Ok(()), + _ => write_pip_shim(venv_dir), + } +} + // ─── Tauri Commands ─── #[tauri::command] @@ -143,7 +232,6 @@ pub async fn check_uv_status() -> Result { version_cmd.arg("--version"); #[cfg(target_os = "windows")] { - version_cmd.creation_flags(CREATE_NO_WINDOW); } let version_output = version_cmd.output(); @@ -214,7 +302,6 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> { }; #[cfg(target_os = "windows")] let mut cmd = { - let mut c = tokio::process::Command::new("powershell"); c.creation_flags(CREATE_NO_WINDOW); c.args([ @@ -236,6 +323,7 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> { cmd.env(&key, &value); } } + crate::claude::apply_proxy_env_to_command(&mut cmd, Some(&window)); let mut child = cmd .spawn() @@ -289,6 +377,7 @@ pub async fn setup_project_venv(project_path: String) -> Result Result/.venv let mut venv_cmd = tokio::process::Command::new(&uv_bin); - venv_cmd.args(["venv", &venv_dir.to_string_lossy()]); + let venv_arg = venv_dir.to_string_lossy().to_string(); + venv_cmd.args(["venv", "--seed", venv_arg.as_str()]); venv_cmd.current_dir(project); #[cfg(target_os = "windows")] { - venv_cmd.creation_flags(CREATE_NO_WINDOW); } let output = venv_cmd @@ -319,6 +408,7 @@ pub async fn setup_project_venv(project_path: String) -> Result })), ); +interface ClaudeSessionInfo { + session_id: string; + title: string; + last_modified: number; +} + +function NativeWindowThemeBridge() { + const { resolvedTheme, theme } = useTheme(); + + useEffect(() => { + const syncNativeTheme = () => { + const isDark = + document.documentElement.classList.contains("dark") || + resolvedTheme === "dark"; + const nativeTheme = isDark ? "dark" : "light"; + + document.documentElement.style.colorScheme = nativeTheme; + invoke("set_native_window_theme", { theme: nativeTheme }) + .catch((err) => { + log.warn("Failed to sync native window theme via Rust command", { + error: String(err), + }); + return getCurrentWindow().setTheme(nativeTheme); + }) + .catch((err) => { + log.warn("Failed to sync native window theme via JS API", { + error: String(err), + }); + }); + }; + + syncNativeTheme(); + + const observer = new MutationObserver(syncNativeTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + systemThemeQuery.addEventListener("change", syncNativeTheme); + + return () => { + observer.disconnect(); + systemThemeQuery.removeEventListener("change", syncNativeTheme); + }; + }, [resolvedTheme, theme]); + + return null; +} + function WorkspaceWithClaude() { const projectRoot = useDocumentStore((s) => s.projectRoot); const initialized = useDocumentStore((s) => s.initialized); - const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false); + const autoResumedProjectRef = useRef(null); + const chatProjectRef = useRef(null); // Update window title useEffect(() => { @@ -39,15 +89,11 @@ function WorkspaceWithClaude() { } }, [projectRoot]); - // Show scientific skills onboarding on first launch useEffect(() => { - if (!initialized) return; - if (shouldShowOnboarding()) { - // Small delay so the workspace renders first - const timer = setTimeout(() => setShowSkillsOnboarding(true), 800); - return () => clearTimeout(timer); - } - }, [initialized]); + if (chatProjectRef.current === projectRoot) return; + chatProjectRef.current = projectRoot; + useClaudeChatStore.getState().resetForProject(projectRoot ?? null); + }, [projectRoot]); // Auto-setup Python venv when project opens useEffect(() => { @@ -66,6 +112,59 @@ function WorkspaceWithClaude() { }); }, [initialized, projectRoot]); + // Open the most recent chat when entering a project. + useEffect(() => { + if (!projectRoot) { + autoResumedProjectRef.current = null; + return; + } + if (!initialized) return; + if (autoResumedProjectRef.current === projectRoot) return; + + const chatState = useClaudeChatStore.getState(); + if (chatState.pendingInitialPrompt) return; + + autoResumedProjectRef.current = projectRoot; + let cancelled = false; + + invoke("list_claude_sessions", { + projectPath: projectRoot, + generateTitles: false, + }) + .then((sessions) => { + if (cancelled) return; + const latest = sessions + .slice() + .sort((a, b) => b.last_modified - a.last_modified)[0]; + + const current = useClaudeChatStore.getState(); + if (current.pendingInitialPrompt || current.isStreaming) { + return; + } + + if (!latest?.session_id) { + current.newSession(); + return; + } + + current.resumeSession(latest.session_id, latest.title).catch((err) => { + log.warn("Failed to auto-resume latest chat session", { + sessionId: latest.session_id, + error: String(err), + }); + }); + }) + .catch((err) => { + log.warn("Failed to auto-resume latest chat session", { + error: String(err), + }); + }); + + return () => { + cancelled = true; + }; + }, [initialized, projectRoot]); + // Consume pending initial prompt from project wizard useEffect(() => { if (!initialized) return; @@ -81,16 +180,7 @@ function WorkspaceWithClaude() { return () => clearTimeout(timer); }, [initialized]); - return ( - <> - - {showSkillsOnboarding && ( - setShowSkillsOnboarding(false)} - /> - )} - - ); + return ; } export function App({ onReady }: { onReady?: () => void }) { @@ -100,10 +190,28 @@ export function App({ onReady }: { onReady?: () => void }) { // Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level useKeyboardShortcuts(); + useEffect(() => { + const preventNativeContextMenu = (event: MouseEvent) => { + if (event.defaultPrevented) return; + event.preventDefault(); + }; + + document.addEventListener("contextmenu", preventNativeContextMenu); + return () => { + document.removeEventListener("contextmenu", preventNativeContextMenu); + }; + }, []); + useEffect(() => { onReady?.(); }, [onReady]); + useEffect(() => { + if (!projectRoot) { + getCurrentWindow().setTitle("ClaudePrism"); + } + }, [projectRoot]); + // Listen for debug panel toggle (Ctrl+Shift+D) useEffect(() => { const handler = () => setShowDebug((prev) => !prev); @@ -113,14 +221,16 @@ export function App({ onReady }: { onReady?: () => void }) { return ( - + + {/* Global macOS titlebar drag region — sits above all content */}
{projectRoot ? : } + {showDebug && (
{ expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.25"); }); - it("restores the saved zoom on startup", async () => { + it("resets stale global zoom on startup", async () => { const webview = getCurrentWebview(); localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.4"); await initializeAppZoom(); - expect(webview.setZoom).toHaveBeenCalledWith(1.4); + expect(webview.setZoom).toHaveBeenCalledWith(DEFAULT_APP_ZOOM); + expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull(); }); it("zooms in, zooms out, and resets around the stored value", async () => { diff --git a/apps/desktop/src/__tests__/lib/model-capabilities.test.ts b/apps/desktop/src/__tests__/lib/model-capabilities.test.ts new file mode 100644 index 0000000..24f8a18 --- /dev/null +++ b/apps/desktop/src/__tests__/lib/model-capabilities.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + getModelCapabilities, + isChatModelOption, +} from "@/lib/model-capabilities"; + +describe("model capabilities", () => { + it("uses provider metadata for vision capability", () => { + expect( + getModelCapabilities({ + model: "custom-model", + metadata: { model_info: { supports_vision: true } }, + }).vision, + ).toBe(true); + + expect( + getModelCapabilities({ + label: "Qwen", + model: "qwen3.6-flash", + metadata: { supports_vision: false }, + }).vision, + ).toBe(false); + }); + + it("recognizes recent Qwen chat models as vision-capable when metadata is missing", () => { + expect( + getModelCapabilities({ + label: "Qwen", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3.5-flash", + }).vision, + ).toBe(true); + + expect( + getModelCapabilities({ + label: "Qwen", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3.6-flash", + }).vision, + ).toBe(true); + + expect( + getModelCapabilities({ + label: "Qwen", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3.6-plus", + }).vision, + ).toBe(true); + + expect( + getModelCapabilities({ + label: "Qwen", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3.5-coder", + }).vision, + ).toBe(false); + }); + + it("filters non-chat model families", () => { + expect( + isChatModelOption({ + label: "Qwen", + model: "text-embedding-v4", + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/__tests__/lib/project-attachments.test.ts b/apps/desktop/src/__tests__/lib/project-attachments.test.ts new file mode 100644 index 0000000..84a7b7a --- /dev/null +++ b/apps/desktop/src/__tests__/lib/project-attachments.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + copyFileToProject: vi.fn(), +})); + +vi.mock("@/lib/tauri/fs", () => ({ + copyFileToProject: mocks.copyFileToProject, +})); + +import { + buildReferenceFilesSection, + importReferenceFiles, +} from "@/lib/project-attachments"; + +describe("project attachment helpers", () => { + beforeEach(() => { + mocks.copyFileToProject.mockReset(); + }); + + it("imports PDFs without creating extracted text files", async () => { + mocks.copyFileToProject.mockResolvedValueOnce("attachments/paper.pdf"); + + const files = await importReferenceFiles("C:/project", [ + "C:/source/paper.pdf", + ]); + + expect(mocks.copyFileToProject).toHaveBeenCalledWith( + "C:/project", + "C:/source/paper.pdf", + "attachments/paper.pdf", + ); + expect(files).toEqual([ + { + relativePath: "attachments/paper.pdf", + }, + ]); + }); + + it("builds a prompt section that keeps PDF references as PDFs", () => { + const section = buildReferenceFilesSection([ + { relativePath: "attachments/paper.pdf" }, + { relativePath: "attachments/data.csv" }, + ]); + + expect(section).toContain("### Reference Files"); + expect(section).toContain("`attachments/paper.pdf` (PDF)"); + expect(section).toContain("`attachments/data.csv`"); + expect(section).not.toContain("extracted text"); + expect(section).not.toContain(".pdf.txt"); + }); +}); diff --git a/apps/desktop/src/__tests__/lib/provider-icons.test.ts b/apps/desktop/src/__tests__/lib/provider-icons.test.ts new file mode 100644 index 0000000..b83d39b --- /dev/null +++ b/apps/desktop/src/__tests__/lib/provider-icons.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + getProviderDisplayName, + getProviderIconSrc, +} from "@/lib/provider-icons"; + +describe("getProviderDisplayName", () => { + it("derives provider names from old custom labels", () => { + expect( + getProviderDisplayName({ + label: "Custom OpenAI API", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3.7-plus", + }), + ).toBe("Qwen"); + + expect( + getProviderDisplayName({ + label: "Custom OpenAI API", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + model: "glm-5.1", + }), + ).toBe("GLM"); + }); + + it("keeps meaningful provider labels for unknown endpoints", () => { + expect( + getProviderDisplayName({ + label: "Acme AI", + baseUrl: "https://models.example.test/v1", + model: "acme-large", + }), + ).toBe("Acme AI"); + }); + + it("recognizes local Ollama endpoints", () => { + const provider = { + label: "Custom OpenAI API", + baseUrl: "http://localhost:11434/v1", + model: "llama3.2", + }; + + expect(getProviderDisplayName(provider)).toBe("Ollama"); + expect(getProviderIconSrc(provider)).toContain("ollama"); + }); +}); diff --git a/apps/desktop/src/__tests__/lib/tauri-fs.test.ts b/apps/desktop/src/__tests__/lib/tauri-fs.test.ts index 53d73cb..28fcea5 100644 --- a/apps/desktop/src/__tests__/lib/tauri-fs.test.ts +++ b/apps/desktop/src/__tests__/lib/tauri-fs.test.ts @@ -23,13 +23,18 @@ describe("tauri fs helpers", () => { expect(getProjectFileType("script.py")).toBe("other"); }); - it("ignores generated and binary file types", () => { + it("ignores LaTeX build artifacts", () => { expect(getProjectFileType("main.aux")).toBeNull(); expect(getProjectFileType("main.synctex.gz")).toBeNull(); - expect(getProjectFileType("archive.zip")).toBeNull(); - expect(getProjectFileType("module.pyc")).toBeNull(); - expect(getProjectFileType("module.PYO")).toBeNull(); - expect(getProjectFileType("native.pyd")).toBeNull(); + }); + + it("keeps imported files with arbitrary extensions visible", () => { + expect(getProjectFileType("archive.zip")).toBe("other"); + expect(getProjectFileType("paper.docx")).toBe("other"); + expect(getProjectFileType("data.xlsx")).toBe("other"); + expect(getProjectFileType("movie.mp4")).toBe("other"); + expect(getProjectFileType("module.pyc")).toBe("other"); + expect(getProjectFileType("native.pyd")).toBe("other"); }); }); @@ -53,7 +58,8 @@ describe("tauri fs helpers", () => { describe("scanProjectFolder", () => { it("does not recurse into generated cache directories", async () => { vi.mocked(readDir).mockImplementation(async (dir: string | URL) => { - if (dir === "/project") { + const dirPath = String(dir); + if (dirPath === "/project") { return [ { name: "__pycache__", isDirectory: true }, { name: "node_modules", isDirectory: true }, @@ -62,11 +68,11 @@ describe("tauri fs helpers", () => { ] as any; } - if (dir === "/project/chapters") { + if (dirPath === "/project/chapters") { return [{ name: "intro.tex", isDirectory: false }] as any; } - throw new Error(`Unexpected readDir path: ${dir}`); + throw new Error(`Unexpected readDir path: ${dirPath}`); }); const result = await scanProjectFolder("/project"); @@ -82,7 +88,7 @@ describe("tauri fs helpers", () => { ]); }); - it("filters bytecode files while keeping real source files", async () => { + it("keeps arbitrary file formats visible as other files", async () => { vi.mocked(readDir).mockResolvedValue([ { name: "module.pyc", isDirectory: false }, { name: "worker.py", isDirectory: false }, @@ -93,10 +99,11 @@ describe("tauri fs helpers", () => { const result = await scanProjectFolder("/project"); expect(result.files.map((file) => file.relativePath)).toEqual([ + "module.pyc", "worker.py", "notes.txt", ]); - expect(stat).toHaveBeenCalledTimes(2); + expect(stat).toHaveBeenCalledTimes(3); expect(result.files.every((file) => file.type === "other")).toBe(true); }); }); diff --git a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts index accf88e..5dca560 100644 --- a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts @@ -23,13 +23,17 @@ vi.mock("@/stores/history-store", () => ({ }, })); -import { useClaudeChatStore } from "@/stores/claude-chat-store"; +import { + CLAUDE_CODE_PROVIDER_ID, + useClaudeChatStore, +} from "@/stores/claude-chat-store"; function resetClaudeChatStore() { useClaudeChatStore.setState({ messages: [], sessionId: null, isStreaming: false, + streamingStartedAt: null, error: null, totalInputTokens: 0, totalOutputTokens: 0, @@ -37,9 +41,13 @@ function resetClaudeChatStore() { { id: "tab-default", title: "New Chat", + projectPath: "/project", sessionId: null, + providerKey: CLAUDE_CODE_PROVIDER_ID, + sessionProviderKey: null, messages: [], isStreaming: false, + streamingStartedAt: null, error: null, totalInputTokens: 0, totalOutputTokens: 0, @@ -47,9 +55,13 @@ function resetClaudeChatStore() { }, ], activeTabId: "tab-default", + activeProjectPath: "/project", pendingInitialPrompt: null, pendingAttachments: [], + pendingPinnedContextRemovalLabels: [], selectedModel: "opus", + selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID, + selectedProviderModels: {}, effortLevel: "medium", _cancelledByUser: false, }); @@ -168,4 +180,265 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => { "[claude] Before Claude edit", ); }); + + it("sends Claude Code when the Claude provider option is selected", async () => { + useClaudeChatStore.setState({ + selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID, + }); + + await useClaudeChatStore.getState().sendPrompt("Use Claude"); + + expect(invoke).toHaveBeenCalledWith( + "execute_claude_code", + expect.objectContaining({ + providerCredentialId: null, + providerModelOverride: null, + }), + ); + }); + + it("starts Claude Code with prior context when switching from a direct provider", async () => { + useClaudeChatStore.setState((state) => ({ + sessionId: "qwen-session", + selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID, + tabs: state.tabs.map((tab) => + tab.id === "tab-default" + ? { + ...tab, + sessionId: "qwen-session", + providerKey: CLAUDE_CODE_PROVIDER_ID, + sessionProviderKey: "openai-compatible:qwen-cred", + messages: [ + { + type: "user", + message: { + content: [{ type: "text", text: "Old DS question" }], + }, + }, + { + type: "assistant", + message: { + content: [{ type: "text", text: "Old DS answer" }], + }, + }, + ], + } + : tab, + ), + })); + + await useClaudeChatStore.getState().sendPrompt("Use Claude now"); + + expect(invoke).toHaveBeenCalledWith( + "execute_claude_code", + expect.objectContaining({ + providerCredentialId: null, + providerModelOverride: null, + prompt: expect.stringContaining("[Provider switch context]"), + }), + ); + const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any).prompt; + expect(prompt).toContain("Old DS question"); + expect(prompt).toContain("Old DS answer"); + expect(prompt).toContain("Use Claude now"); + expect( + vi + .mocked(invoke) + .mock.calls.some(([command]) => command === "resume_claude_code"), + ).toBe(false); + }); + + it("keeps the same backend session when switching between OpenAI-compatible providers", async () => { + useClaudeChatStore.setState((state) => ({ + sessionId: "shared-session", + selectedProviderCredentialId: "deepseek-cred", + selectedProviderModels: { "deepseek-cred": "deepseek-chat" }, + tabs: state.tabs.map((tab) => + tab.id === "tab-default" + ? { + ...tab, + sessionId: "shared-session", + providerKey: "openai-compatible:deepseek-cred", + sessionProviderKey: "openai-compatible:qwen-cred", + } + : tab, + ), + })); + + await useClaudeChatStore.getState().sendPrompt("Use DeepSeek now"); + + expect(invoke).toHaveBeenCalledWith( + "resume_claude_code", + expect.objectContaining({ + sessionId: "shared-session", + providerCredentialId: "deepseek-cred", + providerModelOverride: "deepseek-chat", + }), + ); + }); + + it("passes an OpenAI-compatible model override with the provider credential", async () => { + useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen-cred"); + useClaudeChatStore.setState({ + selectedProviderModels: { "qwen-cred": "qwen3.7-plus" }, + }); + + await useClaudeChatStore.getState().sendPrompt("Use Qwen"); + + expect(invoke).toHaveBeenCalledWith( + "execute_claude_code", + expect.objectContaining({ + providerCredentialId: "qwen-cred", + providerModelOverride: "qwen3.7-plus", + }), + ); + }); +}); + +describe("useClaudeChatStore.resumeSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetClaudeChatStore(); + setMockDocumentState(); + }); + + it("restores token totals from loaded session history", async () => { + vi.mocked(invoke).mockResolvedValueOnce([ + { + type: "user", + message: { content: [{ type: "text", text: "hello" }] }, + }, + { + type: "assistant", + message: { + content: [{ type: "text", text: "hi" }], + usage: { input_tokens: 11, output_tokens: 7 }, + }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 13, output_tokens: 5 }, + }, + ]); + + await useClaudeChatStore.getState().resumeSession("session-123"); + + expect(invoke).toHaveBeenCalledWith("load_session_history", { + projectPath: "/project", + sessionId: "session-123", + }); + + const state = useClaudeChatStore.getState(); + expect(state.sessionId).toBe("session-123"); + expect(state.messages).toHaveLength(3); + expect(state.totalInputTokens).toBe(24); + expect(state.totalOutputTokens).toBe(12); + }); + + it("does not reuse a tab from another project with the same session id", async () => { + vi.mocked(invoke).mockResolvedValueOnce([ + { + type: "user", + message: { content: [{ type: "text", text: "from current project" }] }, + }, + ]); + + useClaudeChatStore.setState((state) => { + const baseTab = state.tabs[0]; + return { + tabs: [ + { + ...baseTab, + id: "tab-current", + projectPath: "/project", + sessionId: null, + messages: [], + }, + { + ...baseTab, + id: "tab-other", + title: "Other project", + projectPath: "/other-project", + sessionId: "shared-session-id", + messages: [ + { + type: "user", + message: { + content: [{ type: "text", text: "from another project" }], + }, + }, + ], + }, + ], + activeTabId: "tab-current", + activeProjectPath: "/project", + messages: [], + sessionId: null, + }; + }); + + await useClaudeChatStore.getState().resumeSession("shared-session-id"); + + expect(invoke).toHaveBeenCalledWith("load_session_history", { + projectPath: "/project", + sessionId: "shared-session-id", + }); + + const state = useClaudeChatStore.getState(); + const otherProjectTab = state.tabs.find((tab) => tab.id === "tab-other"); + expect(state.activeTabId).toBe("tab-current"); + expect(state.activeProjectPath).toBe("/project"); + expect(state.messages[0].message?.content?.[0].text).toBe( + "from current project", + ); + expect(otherProjectTab?.messages[0].message?.content?.[0].text).toBe( + "from another project", + ); + }); + + it("hides internal file and pasted-image context when restoring history", async () => { + const tempImagePath = [ + "C:\\Temp", + "ClaudePrism", + "chat-pastes", + "1781110224092-1-paste-1781110223586-1.png", + ].join("\\"); + const restoredPrompt = [ + "[Currently open file: main.tex]", + "[Selection: Pasted image]", + "[Selected text:", + `[Temporary pasted image: ${tempImagePath}]`, + "Use this image file as visual context for the user's message.", + "]", + "", + "Please inspect this image", + ].join("\n"); + + vi.mocked(invoke).mockResolvedValueOnce([ + { + type: "user", + message: { + content: restoredPrompt, + }, + }, + { + type: "assistant", + message: { + content: [{ type: "text", text: "OK" }], + }, + }, + ]); + + await useClaudeChatStore.getState().resumeSession("session-with-image"); + + const state = useClaudeChatStore.getState(); + const userContent = state.messages[0].message?.content as any; + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + + expect(userContent).toBe("Pasted image\nPlease inspect this image"); + expect(userContent).not.toContain("[Currently open file:"); + expect(userContent).not.toContain("[Temporary pasted image:"); + expect(activeTab?.title).toBe("Please inspect this image"); + }); }); diff --git a/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts index b24ca29..84bd8da 100644 --- a/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts @@ -1,5 +1,17 @@ -import { describe, it, expect } from "vitest"; -import { offsetToLineCol } from "@/stores/claude-chat-store"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + CLAUDE_CODE_PROVIDER_ID, + SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY, + loadSelectedProviderCredentialId, + offsetToLineCol, + useClaudeChatStore, +} from "@/stores/claude-chat-store"; + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + useClaudeChatStore.setState({ selectedProviderCredentialId: null }); +}); describe("offsetToLineCol", () => { it("returns line 1, col 1 for offset 0 on empty string", () => { @@ -38,3 +50,258 @@ describe("offsetToLineCol", () => { expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 }); }); }); + +describe("provider selection persistence", () => { + it("persists Claude Code as an explicit provider selection", () => { + useClaudeChatStore + .getState() + .setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID); + + expect( + sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBe(CLAUDE_CODE_PROVIDER_ID); + expect( + localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBeNull(); + expect(loadSelectedProviderCredentialId()).toBe(CLAUDE_CODE_PROVIDER_ID); + }); + + it("persists and clears OpenAI-compatible provider selections", () => { + useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen"); + + expect( + sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBe("qwen"); + expect( + localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBeNull(); + + useClaudeChatStore.getState().setSelectedProviderCredentialId(null); + + expect( + sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY), + ).toBeNull(); + expect(loadSelectedProviderCredentialId()).toBeNull(); + }); + + it("keeps provider selections isolated between chat tabs", () => { + const store = useClaudeChatStore.getState(); + const firstTabId = store.activeTabId; + + store.setSelectedProviderCredentialId("qwen"); + const secondTabId = store.createTab(); + useClaudeChatStore.getState().setSelectedProviderCredentialId("gemini"); + + expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe( + "gemini", + ); + + useClaudeChatStore.getState().setActiveTab(firstTabId); + expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe( + "qwen", + ); + + useClaudeChatStore.getState().setActiveTab(secondTabId); + expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe( + "gemini", + ); + }); +}); + +describe("project-scoped chat state", () => { + it("resets tabs for a new project without clearing a pending initial prompt", () => { + useClaudeChatStore.setState((state) => { + const baseTab = state.tabs[0]; + const message = { + type: "user" as const, + message: { content: [{ type: "text" as const, text: "old project" }] }, + }; + + return { + pendingInitialPrompt: "build this project", + pendingAttachments: [ + { + label: "old attachment", + filePath: "/project-a/old.png", + selectedText: "old", + }, + ], + pendingPinnedContextRemovalLabels: ["@old.tex"], + activeProjectPath: "/project-a", + activeTabId: "tab-project-a", + sessionId: "session-project-a", + messages: [message], + tabs: [ + { + ...baseTab, + id: "tab-project-a", + title: "Old project chat", + projectPath: "/project-a", + sessionId: "session-project-a", + messages: [message], + }, + ], + }; + }); + + useClaudeChatStore.getState().resetForProject("/project-b"); + + const state = useClaudeChatStore.getState(); + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + expect(state.activeProjectPath).toBe("/project-b"); + expect(activeTab?.projectPath).toBe("/project-b"); + expect(state.sessionId).toBeNull(); + expect(state.messages).toEqual([]); + expect(state.pendingAttachments).toEqual([]); + expect(state.pendingPinnedContextRemovalLabels).toEqual([]); + expect(state.pendingInitialPrompt).toBe("build this project"); + }); +}); + +describe("pinned context removal requests", () => { + it("queues and consumes pinned context labels to remove", () => { + const chat = useClaudeChatStore.getState(); + + chat.requestPinnedContextRemoval(["@main.tex:1:1-1:5"]); + chat.requestPinnedContextRemoval(["@main.tex:2:1-2:5"]); + + expect( + useClaudeChatStore.getState().pendingPinnedContextRemovalLabels, + ).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]); + + expect( + useClaudeChatStore.getState().consumePendingPinnedContextRemovals(), + ).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]); + expect( + useClaudeChatStore.getState().pendingPinnedContextRemovalLabels, + ).toEqual([]); + }); +}); + +describe("queued guidance", () => { + it("queues and consumes guidance for the active tab", () => { + const chat = useClaudeChatStore.getState(); + const tabId = chat.activeTabId; + + chat.clearQueuedGuidance(tabId); + chat.queueGuidance(tabId, "please focus on the API key deletion flow", { + label: "@main.tex:1:1-1:8", + filePath: "main.tex", + selectedText: "selected", + }); + + expect( + useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId) + ?.queuedGuidance, + ).toHaveLength(1); + + const queued = useClaudeChatStore.getState().consumeQueuedGuidance(tabId); + expect(queued?.prompt).toBe("please focus on the API key deletion flow"); + expect(queued?.contextOverride?.filePath).toBe("main.tex"); + expect( + useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId) + ?.queuedGuidance, + ).toHaveLength(0); + }); + + it("can remove and consume a specific queued guidance item", () => { + const chat = useClaudeChatStore.getState(); + const tabId = chat.activeTabId; + + chat.clearQueuedGuidance(tabId); + chat.queueGuidance(tabId, "first"); + chat.queueGuidance(tabId, "second"); + chat.queueGuidance(tabId, "third"); + + const queue = useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId)?.queuedGuidance; + expect(queue?.map((item) => item.prompt)).toEqual([ + "first", + "second", + "third", + ]); + + chat.removeQueuedGuidance(tabId, queue![1].id); + expect( + useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId) + ?.queuedGuidance?.map((item) => item.prompt), + ).toEqual(["first", "third"]); + + const thirdId = useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id; + const selected = chat.consumeQueuedGuidance(tabId, thirdId); + expect(selected?.prompt).toBe("third"); + expect( + useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId) + ?.queuedGuidance?.map((item) => item.prompt), + ).toEqual(["first"]); + }); + + it("marks multiple queued guidance items as displayed in chat", () => { + const chat = useClaudeChatStore.getState(); + const tabId = chat.activeTabId; + + chat.clearQueuedGuidance(tabId); + chat.queueGuidance(tabId, "first"); + chat.queueGuidance(tabId, "second"); + chat.queueGuidance(tabId, "third"); + + const queue = useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId)?.queuedGuidance; + const secondId = queue?.[1].id; + const thirdId = queue?.[2].id; + + expect( + useClaudeChatStore + .getState() + .displayQueuedGuidanceInChat(tabId, secondId), + ).toBe(secondId); + expect( + useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, thirdId), + ).toBe(thirdId); + + expect( + useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId) + ?.queuedGuidance?.map((item) => ({ + prompt: item.prompt, + displayedInChat: item.displayedInChat ?? false, + })), + ).toEqual([ + { prompt: "first", displayedInChat: false }, + { prompt: "second", displayedInChat: true }, + { prompt: "third", displayedInChat: true }, + ]); + }); + + it("consumes displayed guidance before ordinary queued guidance", () => { + const chat = useClaudeChatStore.getState(); + const tabId = chat.activeTabId; + + chat.clearQueuedGuidance(tabId); + chat.queueGuidance(tabId, "first"); + chat.queueGuidance(tabId, "second"); + + const secondId = useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id; + useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, secondId); + + const selected = chat.consumeQueuedGuidance(tabId); + expect(selected?.prompt).toBe("second"); + expect( + useClaudeChatStore + .getState() + .tabs.find((tab) => tab.id === tabId) + ?.queuedGuidance?.map((item) => item.prompt), + ).toEqual(["first"]); + }); +}); diff --git a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts index da6733a..20e1a65 100644 --- a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts @@ -1,4 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { useClaudeSetupStore } from "@/stores/claude-setup-store"; // advanceSteps is module-private — replicate for testing type StepStatus = "pending" | "active" | "complete" | "error"; @@ -95,3 +97,384 @@ describe("advanceSteps", () => { expect(result[2].status).toBe("pending"); }); }); + +describe("useClaudeSetupStore.saveApiKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + useClaudeSetupStore.setState({ + status: "ready", + isInstalling: false, + isLoggingIn: false, + isSavingApiKey: false, + isClearingApiKey: false, + error: null, + version: "1.0.0", + providerKind: "claude-code", + accountEmail: null, + providerModel: null, + providerBaseUrl: null, + openAiCredentials: [], + activeOpenAiCredentialId: null, + installSteps: [], + installLogs: [], + installLogsVisible: false, + loginSteps: [], + }); + }); + + it("requires Claude Code before saving provider credentials", async () => { + useClaudeSetupStore.setState({ status: "not-installed" }); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.deepseek.com/anthropic", + "openai-compatible", + "deepseek-v4-pro", + ); + + expect(success).toBe(false); + expect(invoke).not.toHaveBeenCalled(); + expect(useClaudeSetupStore.getState().error).toBe( + "Install Claude Code before configuring an AI provider.", + ); + }); + + it("verifies OpenAI-compatible credentials before saving them", async () => { + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "check_claude_status") { + return { + installed: true, + authenticated: true, + binary_path: null, + version: "OpenAI-compatible provider", + provider_kind: "openai-compatible", + account_email: null, + provider_model: "deepseek-v4-pro", + provider_base_url: "https://api.deepseek.com/anthropic", + missing_git: false, + }; + } + if (command === "list_openai_compatible_credentials") { + return [ + { + id: "cred-1", + label: "DeepSeek", + model: "deepseek-v4-pro", + base_url: "https://api.deepseek.com/anthropic", + }, + ]; + } + return null; + }); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.deepseek.com/anthropic", + "openai-compatible", + "deepseek-v4-pro", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + model: "deepseek-v4-pro", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + provider: "openai-compatible", + model: "deepseek-v4-pro", + credentialLabel: null, + }); + }); + + it("normalizes legacy DeepSeek root URLs to the native Anthropic endpoint", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.deepseek.com", + "openai-compatible", + "deepseek-v4-pro", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + model: "deepseek-v4-pro", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + provider: "openai-compatible", + model: "deepseek-v4-pro", + credentialLabel: null, + }); + }); + + it("normalizes legacy Qwen compatible URLs to the native Anthropic endpoint", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "openai-compatible", + "qwen3-max-2026-01-23", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + model: "qwen3-max-2026-01-23", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + provider: "openai-compatible", + model: "qwen3-max-2026-01-23", + credentialLabel: null, + }); + }); + + it("preserves Qwen native Anthropic URLs when saving credentials", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://dashscope.aliyuncs.com/apps/anthropic/v1", + "openai-compatible", + "qwen3-max-2026-01-23", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + model: "qwen3-max-2026-01-23", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + provider: "openai-compatible", + model: "qwen3-max-2026-01-23", + credentialLabel: null, + }); + }); + + it("normalizes Moonshot compatible URLs to the native Anthropic endpoint", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.moonshot.cn/v1", + "openai-compatible", + "kimi-k2.5", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://api.moonshot.ai/anthropic", + model: "kimi-k2.5", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://api.moonshot.ai/anthropic", + provider: "openai-compatible", + model: "kimi-k2.5", + credentialLabel: null, + }); + }); + + it("preserves Moonshot Anthropic-looking URLs when saving credentials", async () => { + vi.mocked(invoke).mockResolvedValue(null); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.moonshot.ai/anthropic/v1", + "openai-compatible", + "kimi-k2.5", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "sk-test", + baseUrl: "https://api.moonshot.ai/anthropic", + model: "kimi-k2.5", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "sk-test", + baseUrl: "https://api.moonshot.ai/anthropic", + provider: "openai-compatible", + model: "kimi-k2.5", + credentialLabel: null, + }); + }); + + it("allows local OpenAI-compatible providers without an API key", async () => { + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "check_claude_status") { + return { + installed: true, + authenticated: true, + binary_path: null, + version: "OpenAI-compatible provider", + provider_kind: "openai-compatible", + account_email: null, + provider_model: "llama3.2", + provider_base_url: "http://localhost:11434/v1", + missing_git: false, + }; + } + if (command === "list_openai_compatible_credentials") { + return [ + { + id: "ollama-cred", + label: "Ollama", + model: "llama3.2", + base_url: "http://localhost:11434/v1", + }, + ]; + } + return null; + }); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "", + "http://localhost:11434/v1", + "openai-compatible", + "llama3.2", + "Ollama", + ); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith( + 1, + "verify_openai_compatible_api_key", + { + apiKey: "", + baseUrl: "http://localhost:11434/v1", + model: "llama3.2", + }, + ); + expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", { + apiKey: "", + baseUrl: "http://localhost:11434/v1", + provider: "openai-compatible", + model: "llama3.2", + credentialLabel: "Ollama", + }); + }); + + it("does not save OpenAI-compatible credentials when verification fails", async () => { + vi.mocked(invoke).mockRejectedValueOnce( + new Error("Invalid provider API key"), + ); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.deepseek.com/anthropic", + "openai-compatible", + "deepseek-v4-pro", + ); + + expect(success).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith("verify_openai_compatible_api_key", { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com/anthropic", + model: "deepseek-v4-pro", + }); + expect(useClaudeSetupStore.getState().error).toBe( + "Invalid provider API key", + ); + }); + + it("clears saved credentials and refreshes status", async () => { + useClaudeSetupStore.setState({ + status: "ready", + version: "OpenAI-compatible provider", + providerKind: "openai-compatible", + providerModel: "qwen3-coder-plus", + providerBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + }); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "check_claude_status") { + return { + installed: true, + authenticated: false, + binary_path: null, + version: "1.0.0", + provider_kind: "claude-code", + account_email: null, + provider_model: null, + provider_base_url: null, + missing_git: false, + }; + } + if (command === "list_openai_compatible_credentials") { + return []; + } + return null; + }); + + const success = await useClaudeSetupStore.getState().clearApiKey(); + + expect(success).toBe(true); + expect(invoke).toHaveBeenNthCalledWith(1, "clear_anthropic_api_key"); + expect(invoke).toHaveBeenNthCalledWith(2, "check_claude_status"); + expect(invoke).toHaveBeenNthCalledWith( + 3, + "list_openai_compatible_credentials", + ); + expect(useClaudeSetupStore.getState().status).toBe("not-authenticated"); + expect(useClaudeSetupStore.getState().providerModel).toBeNull(); + expect(useClaudeSetupStore.getState().providerBaseUrl).toBeNull(); + expect(useClaudeSetupStore.getState().isClearingApiKey).toBe(false); + }); +}); diff --git a/apps/desktop/src/__tests__/stores/document-store.test.ts b/apps/desktop/src/__tests__/stores/document-store.test.ts index 2572e4a..5b21f85 100644 --- a/apps/desktop/src/__tests__/stores/document-store.test.ts +++ b/apps/desktop/src/__tests__/stores/document-store.test.ts @@ -3,15 +3,18 @@ import { invoke } from "@tauri-apps/api/core"; import { readDir, readTextFile, + rename, stat, writeTextFile, } from "@tauri-apps/plugin-fs"; import { useDocumentStore, getCurrentPdfBytes, + getCurrentPdfRootId, clearPdfBytesCache, type ProjectFile, } from "@/stores/document-store"; +import { useProjectStore } from "@/stores/project-store"; // Mock history store vi.mock("@/stores/history-store", () => ({ @@ -20,6 +23,7 @@ vi.mock("@/stores/history-store", () => ({ init: vi.fn(() => Promise.resolve()), loadSnapshots: vi.fn(() => Promise.resolve()), createSnapshot: vi.fn(() => Promise.resolve()), + reset: vi.fn(), })), }, })); @@ -66,6 +70,10 @@ describe("useDocumentStore", () => { isSaving: false, initialized: true, }); + useProjectStore.setState({ + recentProjects: [], + lastProjectFolder: null, + }); }); describe("getActiveFile logic", () => { @@ -124,7 +132,8 @@ describe("useDocumentStore", () => { it("skips Python cache directories and bytecode files during open", async () => { vi.mocked(invoke).mockResolvedValue(undefined as never); vi.mocked(readDir).mockImplementation(async (dir: string | URL) => { - if (dir === "/project") { + const dirPath = String(dir); + if (dirPath === "/project") { return [ { name: "__pycache__", isDirectory: true }, { name: "main.tex", isDirectory: false }, @@ -133,17 +142,18 @@ describe("useDocumentStore", () => { ] as any; } - throw new Error(`Unexpected readDir path: ${dir}`); + throw new Error(`Unexpected readDir path: ${dirPath}`); }); vi.mocked(stat).mockResolvedValue({ size: 32 } as any); vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => { - if (path === "/project/main.tex") { + const filePath = String(path); + if (filePath === "/project/main.tex") { return "\\documentclass{article}"; } - if (path === "/project/tool.py") { + if (filePath === "/project/tool.py") { return "print('hello')"; } - throw new Error(`Unexpected readTextFile path: ${path}`); + throw new Error(`Unexpected readTextFile path: ${filePath}`); }); await useDocumentStore.getState().openProject("/project"); @@ -160,6 +170,70 @@ describe("useDocumentStore", () => { }); }); + describe("renameProject", () => { + it("renames the project folder and reopens the new path", async () => { + vi.mocked(invoke).mockResolvedValue(undefined as never); + vi.mocked(readDir).mockResolvedValue([ + { name: "main.tex", isDirectory: false }, + ] as any); + vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}"); + useProjectStore.setState({ + recentProjects: [{ path: "/work/old", name: "old", lastOpened: 1 }], + lastProjectFolder: "/work", + }); + useDocumentStore.setState({ + projectRoot: "/work/old", + files: [makeFile({ absolutePath: "/work/old/main.tex" })], + }); + + await useDocumentStore.getState().renameProject("renamed"); + + expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed"); + expect(invoke).toHaveBeenCalledWith("migrate_project_sessions", { + oldProjectPath: "/work/old", + newProjectPath: "/work/renamed", + }); + expect(invoke).toHaveBeenCalledWith("allow_project_directory", { + rootPath: "/work/renamed", + }); + expect(useDocumentStore.getState().projectRoot).toBe("/work/renamed"); + expect(useProjectStore.getState().recentProjects[0]).toMatchObject({ + path: "/work/renamed", + name: "renamed", + }); + expect( + useProjectStore + .getState() + .recentProjects.some((project) => project.path === "/work/old"), + ).toBe(false); + expect(useProjectStore.getState().lastProjectFolder).toBe("/work"); + }); + + it("saves dirty files before renaming the project folder", async () => { + vi.mocked(invoke).mockResolvedValue(undefined as never); + vi.mocked(writeTextFile).mockResolvedValue(undefined); + vi.mocked(readDir).mockResolvedValue([ + { name: "main.tex", isDirectory: false }, + ] as any); + vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}"); + useDocumentStore.setState({ + projectRoot: "/work/old", + files: [ + makeFile({ + absolutePath: "/work/old/main.tex", + content: "dirty", + isDirty: true, + }), + ], + }); + + await useDocumentStore.getState().renameProject("renamed"); + + expect(writeTextFile).toHaveBeenCalledWith("/work/old/main.tex", "dirty"); + expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed"); + }); + }); + describe("insertAtCursor", () => { it("inserts text at cursor position", () => { useDocumentStore.getState().insertAtCursor(", Beautiful"); @@ -370,6 +444,39 @@ describe("useDocumentStore", () => { expect(state.cursorPosition).toBe(100); expect(state.selectionRange).toBeNull(); }); + + it("keeps the current PDF when switching to a non-tex file", () => { + const pdfBytes = new Uint8Array([1, 2, 3]); + useDocumentStore.setState({ + files: [ + makeFile({ + content: + "\\documentclass{article}\\begin{document}Hi\\end{document}", + }), + makeFile({ + id: "analysis.py", + name: "analysis.py", + relativePath: "analysis.py", + absolutePath: "/project/analysis.py", + type: "other", + content: "print('hello')", + }), + ], + activeFileId: "main.tex", + selectionRange: { start: 0, end: 3 }, + }); + useDocumentStore.getState().setPdfData(pdfBytes, "main.tex"); + const revisionBefore = useDocumentStore.getState().pdfRevision; + + useDocumentStore.getState().setActiveFile("analysis.py"); + + const state = useDocumentStore.getState(); + expect(state.activeFileId).toBe("analysis.py"); + expect(state.selectionRange).toBeNull(); + expect(state.pdfRevision).toBe(revisionBefore); + expect(getCurrentPdfRootId()).toBe("main.tex"); + expect(getCurrentPdfBytes()).toEqual(pdfBytes); + }); }); describe("saveFile", () => { diff --git a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts index 6f743e1..1023f2a 100644 --- a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts +++ b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts @@ -36,6 +36,7 @@ function resetStores() { messages: [], sessionId: null, isStreaming: false, + streamingStartedAt: null, error: null, totalInputTokens: 0, totalOutputTokens: 0, @@ -43,9 +44,13 @@ function resetStores() { { id: "tab-default", title: "New Chat", + projectPath: "/project", sessionId: null, + providerKey: null, + sessionProviderKey: null, messages: [], isStreaming: false, + streamingStartedAt: null, error: null, totalInputTokens: 0, totalOutputTokens: 0, @@ -53,6 +58,7 @@ function resetStores() { }, ], activeTabId: "tab-default", + activeProjectPath: "/project", _cancelledByUser: false, }); useProposedChangesStore.setState({ changes: [] }); @@ -238,6 +244,32 @@ describe("Multi-tab merge triggers", () => { expect(tabBState.isStreaming).toBe(true); }); + it("preserves streaming start time when switching tabs", () => { + const chat = useClaudeChatStore.getState(); + const tabB = chat.createTab(); + const startedAt = Date.now() - 12_000; + + useClaudeChatStore.setState((s) => ({ + tabs: s.tabs.map((t) => + t.id === "tab-default" + ? { ...t, isStreaming: true, streamingStartedAt: startedAt } + : t, + ), + activeTabId: "tab-default", + isStreaming: true, + streamingStartedAt: startedAt, + })); + + chat.setActiveTab(tabB); + chat.setActiveTab("tab-default"); + + const state = useClaudeChatStore.getState(); + expect(state.streamingStartedAt).toBe(startedAt); + expect( + state.tabs.find((t) => t.id === "tab-default")!.streamingStartedAt, + ).toBe(startedAt); + }); + it("_appendMessage routes to the specified tab, not the active tab", () => { const chat = useClaudeChatStore.getState(); const tabB = chat.createTab(); @@ -271,6 +303,89 @@ describe("Multi-tab merge triggers", () => { expect(state.messages).toHaveLength(0); }); + it("_appendMessage merges direct provider streaming deltas", () => { + const chat = useClaudeChatStore.getState(); + + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { content: [{ type: "text", text: "Hello" }] }, + }); + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { content: [{ type: "text", text: " world" }] }, + }); + + const messages = useClaudeChatStore.getState().messages; + expect(messages).toHaveLength(1); + expect(messages[0].message?.content?.[0].text).toBe("Hello world"); + }); + + it("_appendMessage merges direct provider streaming thinking deltas", () => { + const chat = useClaudeChatStore.getState(); + + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { + content: [ + { type: "thinking", thinking: "Reason A. " }, + { type: "text", text: "Hello" }, + ], + }, + }); + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { + content: [ + { type: "thinking", thinking: "Reason B." }, + { type: "text", text: " world" }, + ], + }, + }); + + const messages = useClaudeChatStore.getState().messages; + expect(messages).toHaveLength(1); + expect(messages[0].message?.content?.[0].type).toBe("thinking"); + expect(messages[0].message?.content?.[0].thinking).toBe( + "Reason A. Reason B.", + ); + expect(messages[0].message?.content?.[1].text).toBe("Hello world"); + }); + + it("_appendMessage replaces streaming deltas with final direct provider message", () => { + const chat = useClaudeChatStore.getState(); + + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { content: [{ type: "text", text: "Draft" }] }, + }); + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_final", + message: { + content: [ + { type: "text", text: "Final" }, + { + type: "tool_use", + id: "tool-1", + name: "Read", + input: { file_path: "main.tex" }, + }, + ], + }, + }); + + const messages = useClaudeChatStore.getState().messages; + expect(messages).toHaveLength(1); + expect(messages[0].subtype).toBe("streaming_final"); + expect(messages[0].message?.content?.[0].text).toBe("Final"); + expect(messages[0].message?.content?.[1].type).toBe("tool_use"); + }); + it("_setSessionId routes to the specified tab", () => { const chat = useClaudeChatStore.getState(); const tabB = chat.createTab(); diff --git a/apps/desktop/src/__tests__/stores/project-store.test.ts b/apps/desktop/src/__tests__/stores/project-store.test.ts index acfd4a0..4bee105 100644 --- a/apps/desktop/src/__tests__/stores/project-store.test.ts +++ b/apps/desktop/src/__tests__/stores/project-store.test.ts @@ -30,6 +30,18 @@ describe("useProjectStore", () => { expect(recentProjects[1].path).toBe("/b"); }); + it("normalizes trailing separators when deduplicating", () => { + const store = useProjectStore.getState(); + store.addRecentProject("C:\\Projects\\ClaudePrism\\paper\\"); + store.addRecentProject("C:\\Projects\\ClaudePrism\\paper"); + const { recentProjects } = useProjectStore.getState(); + expect(recentProjects).toHaveLength(1); + expect(recentProjects[0]).toMatchObject({ + path: "C:\\Projects\\ClaudePrism\\paper", + name: "paper", + }); + }); + it("limits to MAX_RECENT (10) entries", () => { const store = useProjectStore.getState(); for (let i = 0; i < 12; i++) { @@ -67,4 +79,38 @@ describe("useProjectStore", () => { expect(recentProjects[0].path).toBe("/b"); }); }); + + describe("renameRecentProject", () => { + it("replaces the old recent project path with the new folder path", () => { + const store = useProjectStore.getState(); + store.addRecentProject("/work/old"); + store.addRecentProject("/work/other"); + store.renameRecentProject("/work/old", "/work/new"); + + const { recentProjects } = useProjectStore.getState(); + expect(recentProjects[0]).toMatchObject({ + path: "/work/new", + name: "new", + }); + expect( + recentProjects.some((project) => project.path === "/work/old"), + ).toBe(false); + expect( + recentProjects.some((project) => project.path === "/work/other"), + ).toBe(true); + }); + + it("matches renamed paths even when the old recent path has a trailing slash", () => { + const store = useProjectStore.getState(); + store.addRecentProject("/work/old/"); + store.renameRecentProject("/work/old", "/work/new/"); + + const { recentProjects } = useProjectStore.getState(); + expect(recentProjects).toHaveLength(1); + expect(recentProjects[0]).toMatchObject({ + path: "/work/new", + name: "new", + }); + }); + }); }); diff --git a/apps/desktop/src/assets/providers/anthropic.svg b/apps/desktop/src/assets/providers/anthropic.svg new file mode 100644 index 0000000..26d103d --- /dev/null +++ b/apps/desktop/src/assets/providers/anthropic.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/assets/providers/deepseek.svg b/apps/desktop/src/assets/providers/deepseek.svg new file mode 100644 index 0000000..4190d3f --- /dev/null +++ b/apps/desktop/src/assets/providers/deepseek.svg @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/apps/desktop/src/assets/providers/gemini-color.svg b/apps/desktop/src/assets/providers/gemini-color.svg new file mode 100644 index 0000000..0f2e398 --- /dev/null +++ b/apps/desktop/src/assets/providers/gemini-color.svg @@ -0,0 +1 @@ +Gemini diff --git a/apps/desktop/src/assets/providers/moonshot.svg b/apps/desktop/src/assets/providers/moonshot.svg new file mode 100644 index 0000000..829ab29 --- /dev/null +++ b/apps/desktop/src/assets/providers/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI diff --git a/apps/desktop/src/assets/providers/ollama.svg b/apps/desktop/src/assets/providers/ollama.svg new file mode 100644 index 0000000..4f534cf --- /dev/null +++ b/apps/desktop/src/assets/providers/ollama.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/assets/providers/openai.svg b/apps/desktop/src/assets/providers/openai.svg new file mode 100644 index 0000000..8c3e68d --- /dev/null +++ b/apps/desktop/src/assets/providers/openai.svg @@ -0,0 +1 @@ +OpenAI diff --git a/apps/desktop/src/assets/providers/qwen.svg b/apps/desktop/src/assets/providers/qwen.svg new file mode 100644 index 0000000..8e09246 --- /dev/null +++ b/apps/desktop/src/assets/providers/qwen.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/assets/providers/zhipu-color.svg b/apps/desktop/src/assets/providers/zhipu-color.svg new file mode 100644 index 0000000..40e5bea --- /dev/null +++ b/apps/desktop/src/assets/providers/zhipu-color.svg @@ -0,0 +1 @@ +Zhipu diff --git a/apps/desktop/src/assets/vscode.svg b/apps/desktop/src/assets/vscode.svg new file mode 100644 index 0000000..b9a5693 --- /dev/null +++ b/apps/desktop/src/assets/vscode.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index c283062..35f3937 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -1,5 +1,7 @@ import { + type CSSProperties, type FC, + type KeyboardEvent, useCallback, useEffect, useLayoutEffect, @@ -8,6 +10,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; +import { open } from "@tauri-apps/plugin-dialog"; import { ArrowUpIcon, SquareIcon, @@ -24,32 +27,144 @@ import { SparklesIcon, RabbitIcon, LayersIcon, + PlusIcon, + Trash2Icon, + Loader2Icon, + CornerDownRightIcon, + ListEndIcon, } from "lucide-react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs"; -import { join } from "@tauri-apps/api/path"; +import { writeFile, mkdir, exists, remove } from "@tauri-apps/plugin-fs"; +import { join, tempDir } from "@tauri-apps/api/path"; import { invoke } from "@tauri-apps/api/core"; import { - useClaudeChatStore, + CLAUDE_CODE_PROVIDER_ID, + loadSelectedProviderCredentialId, offsetToLineCol, + type PromptContextOverride, + type QueuedGuidance, + useClaudeChatStore, } from "@/stores/claude-chat-store"; +import { + useClaudeSetupStore, + type OpenAiCompatibleCredentialInfo, +} from "@/stores/claude-setup-store"; import { useDocumentStore, type ProjectFile } from "@/stores/document-store"; import { getUniqueTargetName } from "@/lib/tauri/fs"; +import { + getProviderDisplayName, + getProviderIconSrc, +} from "@/lib/provider-icons"; +import { + getModelCapabilities, + isChatModelOption, + modelInfoId, + type OpenAiCompatibleModelInfo, + rememberModelListCapabilityMetadata, +} from "@/lib/model-capabilities"; +import { ModelCapabilityBadges } from "@/components/model-capability-badges"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { ClaudeSetup } from "@/components/claude-setup"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker"; import { createLogger } from "@/lib/debug/logger"; const log = createLogger("chat-composer"); +const EMPTY_GUIDANCE: QueuedGuidance[] = []; // Re-export for other modules export type { SlashCommand }; interface PinnedContext { - label: string; // @file:line:col-line:col + label: string; filePath: string; selectedText: string; imageDataUrl?: string; // thumbnail for captured images + isTemporary?: boolean; +} + +function pastedFileExtension(file: File) { + const namedExt = file.name.split(".").pop()?.trim().toLowerCase(); + if (namedExt && namedExt !== file.name.toLowerCase()) return namedExt; + return file.type.split("/")[1]?.split("+")[0] || "png"; +} + +function safePastedFileName(file: File, index: number) { + const ext = pastedFileExtension(file).replace(/[^a-z0-9]/g, "") || "png"; + const base = + file.name && file.name !== "image.png" + ? file.name.replace(/\.[^.]+$/, "") + : `paste-${Date.now()}-${index + 1}`; + return `${base.replace(/[^a-zA-Z0-9._-]/g, "_")}.${ext}`; +} + +function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error ?? new Error("Read failed")); + reader.readAsDataURL(file); + }); +} + +function temporaryFilePaths(contexts: PinnedContext[]) { + return contexts + .filter((context) => context.isTemporary) + .map((context) => context.filePath); +} + +async function cleanupTemporaryFilePaths(paths: string[] | undefined) { + if (!paths?.length) return; + await Promise.all( + paths.map(async (path) => { + try { + await remove(path); + } catch (err) { + log.warn("Failed to remove temporary pasted file", { + path, + error: String(err), + }); + } + }), + ); +} + +function cleanupTemporaryPinnedContext(context: PinnedContext) { + if (!context.isTemporary) return; + void cleanupTemporaryFilePaths([context.filePath]); +} + +function pinnedContextDedupKey(context: PinnedContext) { + return context.isTemporary + ? `temporary:${context.filePath}` + : `label:${context.label}`; +} + +function appendUniquePinnedContexts( + current: PinnedContext[], + next: PinnedContext[], +) { + const seen = new Set(current.map(pinnedContextDedupKey)); + const unique = next.filter((context) => { + const key = pinnedContextDedupKey(context); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return [...current, ...unique]; +} + +function isPdfPath(path: string) { + return path.toLowerCase().endsWith(".pdf"); } function getFileIcon(file: ProjectFile) { @@ -66,22 +181,206 @@ function getFileIcon(file: ProjectFile) { return ; } +function formatGuidanceText(guidance: QueuedGuidance) { + return guidance.contextOverride?.label + ? `${guidance.contextOverride.label} - ${guidance.prompt}` + : guidance.prompt; +} + +type EffortLevel = "low" | "medium" | "high"; +const EFFORT_LEVELS: EffortLevel[] = ["low", "medium", "high"]; + +function effortShortLabel(level: EffortLevel) { + return level === "low" ? "L" : level === "medium" ? "M" : "H"; +} + +function effortDisplayLabel(level: EffortLevel) { + return effortShortLabel(level); +} + +function claudeModelDisplayName(model: string) { + switch (model) { + case "sonnet": + return "Sonnet"; + case "opus": + return "Opus"; + case "haiku": + return "Haiku"; + case "opusplan": + return "OpusPlan"; + default: + return model; + } +} + +function EffortControls({ + effortLevel, + setEffortLevel, +}: { + effortLevel: EffortLevel; + setEffortLevel: (level: EffortLevel) => void; +}) { + return ( + <> +
+
+ Effort +
+
+ {EFFORT_LEVELS.map((level) => ( + + ))} +
+ + ); +} + export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const sendPrompt = useClaudeChatStore((s) => s.sendPrompt); + const setChatError = useClaudeChatStore((s) => s._setError); + const queueGuidance = useClaudeChatStore((s) => s.queueGuidance); const cancelExecution = useClaudeChatStore((s) => s.cancelExecution); + const removeQueuedGuidance = useClaudeChatStore( + (s) => s.removeQueuedGuidance, + ); + const forceQueuedGuidanceNow = useClaudeChatStore( + (s) => s.forceQueuedGuidanceNow, + ); const isStreaming = useClaudeChatStore((s) => s.isStreaming); const selectedModel = useClaudeChatStore((s) => s.selectedModel); const setSelectedModel = useClaudeChatStore((s) => s.setSelectedModel); + const selectedProviderCredentialId = useClaudeChatStore( + (s) => s.selectedProviderCredentialId, + ); + const setSelectedProviderCredentialId = useClaudeChatStore( + (s) => s.setSelectedProviderCredentialId, + ); + const selectedProviderModels = useClaudeChatStore( + (s) => s.selectedProviderModels, + ); + const setSelectedProviderModel = useClaudeChatStore( + (s) => s.setSelectedProviderModel, + ); const effortLevel = useClaudeChatStore((s) => s.effortLevel); const setEffortLevel = useClaudeChatStore((s) => s.setEffortLevel); const activeTabId = useClaudeChatStore((s) => s.activeTabId); + const queuedGuidance = useClaudeChatStore( + (s) => + s.tabs.find((tab) => tab.id === s.activeTabId)?.queuedGuidance ?? + EMPTY_GUIDANCE, + ); + const visibleQueuedGuidance = useMemo( + () => queuedGuidance.filter((guidance) => !guidance.displayedInChat), + [queuedGuidance], + ); + const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials); + const activeOpenAiCredentialId = useClaudeSetupStore( + (s) => s.activeOpenAiCredentialId, + ); + const providerKind = useClaudeSetupStore((s) => s.providerKind); + const setupStatus = useClaudeSetupStore((s) => s.status); + const claudeProviderConfigured = useClaudeSetupStore( + (s) => s.claudeProviderConfigured, + ); + const deleteApiCredential = useClaudeSetupStore((s) => s.deleteApiCredential); + const configuredOpenAiCredential = + selectedProviderCredentialId && + selectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID + ? (openAiCredentials.find( + (credential) => credential.id === selectedProviderCredentialId, + ) ?? null) + : null; + const fallbackProviderCredential = + (activeOpenAiCredentialId + ? openAiCredentials.find( + (credential) => credential.id === activeOpenAiCredentialId, + ) + : null) ?? + openAiCredentials[0] ?? + null; + const showClaudeProvider = + claudeProviderConfigured || + (openAiCredentials.length === 0 && setupStatus !== "checking"); + const selectedProviderCredential = + configuredOpenAiCredential ?? + (!showClaudeProvider ? fallbackProviderCredential : null); + const claudeProviderActive = + showClaudeProvider && !selectedProviderCredential; + const providerSelectionReady = + claudeProviderActive || !!selectedProviderCredential; + const selectedProviderModel = selectedProviderCredential + ? selectedProviderModels[selectedProviderCredential.id] || + selectedProviderCredential.model + : null; + const directProviderModel = + selectedProviderModel || selectedProviderCredential?.model || "Provider"; + const selectedProviderSupportsVision = selectedProviderCredential + ? getModelCapabilities({ + label: selectedProviderCredential.label, + baseUrl: selectedProviderCredential.base_url, + model: directProviderModel, + }).vision + : true; + const selectedProviderDisplayName = selectedProviderCredential + ? getProviderDisplayName({ + label: selectedProviderCredential.label, + baseUrl: selectedProviderCredential.base_url, + model: selectedProviderCredential.model, + }) + : "Provider"; + const selectedProviderIconSrc = selectedProviderCredential + ? getProviderIconSrc({ + label: selectedProviderCredential.label, + baseUrl: selectedProviderCredential.base_url, + model: selectedProviderCredential.model, + }) + : null; + const claudeCodeIconSrc = getProviderIconSrc({ label: "Anthropic" }); + const [providerModelOptions, setProviderModelOptions] = useState< + Record + >({}); + const [providerModelLoadingId, setProviderModelLoadingId] = useState< + string | null + >(null); + const [providerModelError, setProviderModelError] = useState( + null, + ); + const [providerSetupOpen, setProviderSetupOpen] = useState(false); + const [providerDeleteTarget, setProviderDeleteTarget] = + useState(null); + const [providerDeleteError, setProviderDeleteError] = useState( + null, + ); + const [deletingProviderId, setDeletingProviderId] = useState( + null, + ); const [input, setInput] = useState(""); + const hasInput = input.trim().length > 0; const textareaRef = useRef(null); + const hadStoredProviderSelectionRef = useRef( + loadSelectedProviderCredentialId() !== null, + ); + const initialProviderSyncDoneRef = useRef(false); // Model picker state const [modelPickerOpen, setModelPickerOpen] = useState(false); const modelPickerRef = useRef(null); const modelButtonRef = useRef(null); + const providerModelListRef = useRef(null); + const providerModelItemRefs = useRef< + Record + >({}); const [pickerPos, setPickerPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0, @@ -97,8 +396,185 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { }); }, [modelPickerOpen]); + useEffect(() => { + if ( + !initialProviderSyncDoneRef.current && + setupStatus !== "checking" && + setupStatus !== "error" + ) { + initialProviderSyncDoneRef.current = true; + if ( + !hadStoredProviderSelectionRef.current && + providerKind === "openai-compatible" && + fallbackProviderCredential && + selectedProviderCredentialId !== fallbackProviderCredential.id + ) { + setSelectedProviderCredentialId(fallbackProviderCredential.id); + return; + } + } + + const selectedOpenAiCredentialMissing = + selectedProviderCredentialId && + selectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID && + !openAiCredentials.some( + (credential) => credential.id === selectedProviderCredentialId, + ); + const selectedClaudeUnavailable = + selectedProviderCredentialId === CLAUDE_CODE_PROVIDER_ID && + !showClaudeProvider; + const noProviderSelected = + !selectedProviderCredentialId && !showClaudeProvider; + + if ( + selectedOpenAiCredentialMissing || + selectedClaudeUnavailable || + noProviderSelected + ) { + setSelectedProviderCredentialId( + fallbackProviderCredential?.id ?? + (showClaudeProvider ? CLAUDE_CODE_PROVIDER_ID : null), + ); + } + }, [ + fallbackProviderCredential?.id, + openAiCredentials, + providerKind, + selectedProviderCredentialId, + setSelectedProviderCredentialId, + setupStatus, + showClaudeProvider, + ]); + + const handleDeleteProviderCredential = useCallback( + async (credentialId: string) => { + if (deletingProviderId) return; + + const remainingCredentials = openAiCredentials.filter( + (credential) => credential.id !== credentialId, + ); + const deletingSelected = + selectedProviderCredentialId === credentialId || + selectedProviderCredential?.id === credentialId; + + setDeletingProviderId(credentialId); + setProviderDeleteError(null); + try { + const success = await deleteApiCredential(credentialId); + if (!success) { + setProviderDeleteError("Failed to delete this provider."); + return; + } + + setProviderModelOptions((prev) => { + const next = { ...prev }; + delete next[credentialId]; + return next; + }); + + if (deletingSelected) { + const nextCredential = remainingCredentials[0] ?? null; + if (nextCredential) { + setSelectedProviderCredentialId(nextCredential.id); + } else { + setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID); + } + } + setProviderDeleteTarget(null); + } finally { + setDeletingProviderId(null); + } + }, + [ + deleteApiCredential, + deletingProviderId, + openAiCredentials, + selectedProviderCredential?.id, + selectedProviderCredentialId, + setSelectedProviderCredentialId, + ], + ); + + useEffect(() => { + if (!modelPickerOpen || !selectedProviderCredential) return; + + const credentialId = selectedProviderCredential.id; + if (providerModelOptions[credentialId]) return; + + let cancelled = false; + setProviderModelLoadingId(credentialId); + setProviderModelError(null); + + invoke>( + "list_openai_compatible_credential_models", + { + credentialId, + }, + ) + .then((models) => { + if (cancelled) return; + rememberModelListCapabilityMetadata( + selectedProviderCredential.base_url, + models, + ); + const modelIds = models + .filter((model) => + isChatModelOption({ + label: selectedProviderCredential.label, + baseUrl: selectedProviderCredential.base_url, + model: modelInfoId(model), + metadata: typeof model === "string" ? undefined : model.metadata, + }), + ) + .map(modelInfoId); + const options = Array.from(new Set(modelIds.filter(Boolean))); + if ( + selectedProviderCredential.model && + !options.includes(selectedProviderCredential.model) + ) { + options.push(selectedProviderCredential.model); + } + setProviderModelOptions((prev) => ({ + ...prev, + [credentialId]: options, + })); + }) + .catch((err: any) => { + if (cancelled) return; + setProviderModelError(err?.message || String(err)); + setProviderModelOptions((prev) => ({ + ...prev, + [credentialId]: [selectedProviderCredential.model].filter(Boolean), + })); + }) + .finally(() => { + if (!cancelled) { + setProviderModelLoadingId(null); + } + }); + + return () => { + cancelled = true; + }; + }, [modelPickerOpen, providerModelOptions, selectedProviderCredential]); + // Pinned contexts — supports multiple files/selections const [pinnedContexts, setPinnedContexts] = useState([]); + const hasPinnedImages = pinnedContexts.some( + (context) => context.imageDataUrl, + ); + const imageCompatibilityError = + selectedProviderCredential && + hasPinnedImages && + !selectedProviderSupportsVision + ? `${selectedProviderDisplayName} ${directProviderModel} does not support image input. Remove the pasted image or switch to a vision-capable model.` + : null; + + useEffect(() => { + if (imageCompatibilityError) { + setChatError(activeTabId, imageCompatibilityError); + } + }, [activeTabId, imageCompatibilityError, setChatError]); // File drop state const [isDragOver, setIsDragOver] = useState(false); @@ -162,6 +638,12 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const consumePendingAttachments = useClaudeChatStore( (s) => s.consumePendingAttachments, ); + const pendingPinnedContextRemovalLabels = useClaudeChatStore( + (s) => s.pendingPinnedContextRemovalLabels, + ); + const consumePendingPinnedContextRemovals = useClaudeChatStore( + (s) => s.consumePendingPinnedContextRemovals, + ); // Focus textarea when the drawer opens const prevOpenRef = useRef(false); @@ -177,14 +659,22 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const attachments = consumePendingAttachments(); if (attachments.length === 0) return; setPinnedContexts((prev) => { - const existingLabels = new Set(prev.map((c) => c.label)); - const unique = attachments.filter((a) => !existingLabels.has(a.label)); - return [...prev, ...unique]; + return appendUniquePinnedContexts(prev, attachments); }); // Focus textarea so user can type immediately setTimeout(() => textareaRef.current?.focus(), 0); }, [pendingAttachments, consumePendingAttachments]); + useEffect(() => { + if (pendingPinnedContextRemovalLabels.length === 0) return; + const labels = consumePendingPinnedContextRemovals(); + if (labels.length === 0) return; + const labelsToRemove = new Set(labels); + setPinnedContexts((prev) => + prev.filter((context) => !labelsToRemove.has(context.label)), + ); + }, [pendingPinnedContextRemovalLabels, consumePendingPinnedContextRemovals]); + const currentContextLabel = useMemo(() => { if (!selectionRange) return null; const file = files.find((f) => f.id === activeFileId); @@ -246,8 +736,27 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { .catch(() => setSlashCommands([])); }, [slashQuery !== null, projectRoot]); + const buildPinnedContextForFile = useCallback( + async (file: ProjectFile): Promise => { + const isTextFile = + file.type === "tex" || + file.type === "bib" || + file.type === "style" || + file.type === "other"; + + return { + label: `@${file.relativePath}`, + filePath: file.relativePath, + selectedText: isTextFile + ? (file.content ?? "") + : `[Referenced file: ${file.relativePath} (${file.type} file)]`, + }; + }, + [], + ); + const selectMention = useCallback( - (file: ProjectFile) => { + async (file: ProjectFile) => { // Replace @query with empty and pin the file as context const textarea = textareaRef.current; if (!textarea) return; @@ -261,26 +770,13 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { setMentionQuery(null); // Pin the whole file as context - const isTextFile = - file.type === "tex" || - file.type === "bib" || - file.type === "style" || - file.type === "other"; - setPinnedContexts((prev) => [ - ...prev, - { - label: `@${file.relativePath}`, - filePath: file.relativePath, - selectedText: isTextFile - ? (file.content ?? "") - : `[Referenced file: ${file.relativePath} (${file.type} file)]`, - }, - ]); + const context = await buildPinnedContextForFile(file); + setPinnedContexts((prev) => [...prev, context]); // Refocus textarea setTimeout(() => textarea.focus(), 0); }, - [input], + [buildPinnedContextForFile, input], ); const selectSlashCommand = useCallback((command: SlashCommand) => { @@ -330,18 +826,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { ); if (imported) { - const isText = - imported.type === "tex" || - imported.type === "bib" || - imported.type === "style" || - imported.type === "other"; - newContexts.push({ - label: `@${relativePath}`, - filePath: relativePath, - selectedText: isText - ? (imported.content ?? "") - : `[Attached file: ${relativePath} (${imported.type} file)]`, - }); + newContexts.push(await buildPinnedContextForFile(imported)); } else { // File imported but type might be filtered out — still pin as reference newContexts.push({ @@ -354,12 +839,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { if (newContexts.length > 0) { setPinnedContexts((prev) => { - // Deduplicate by label - const existingLabels = new Set(prev.map((c) => c.label)); - const unique = newContexts.filter( - (c) => !existingLabels.has(c.label), - ); - return [...prev, ...unique]; + return appendUniquePinnedContexts(prev, newContexts); }); } } finally { @@ -367,6 +847,23 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { } }; + const handleAttachFiles = useCallback(async () => { + if (!projectRoot) return; + const selected = await open({ + multiple: true, + directory: false, + title: "Attach files", + }); + const paths = + typeof selected === "string" + ? [selected] + : Array.isArray(selected) + ? selected + : []; + if (paths.length === 0) return; + await handleFileDropRef.current(paths); + }, [projectRoot]); + // Listen for Tauri drag-drop events (OS file drops) useEffect(() => { let unlisten: (() => void) | undefined; @@ -425,7 +922,47 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const newContexts: PinnedContext[] = []; - for (const file of fileItems) { + for (const [index, file] of fileItems.entries()) { + if (file.type.startsWith("image/")) { + try { + const fileName = safePastedFileName(file, index); + const tempRoot = await join( + await tempDir(), + "ClaudePrism", + "chat-pastes", + ); + if (!(await exists(tempRoot))) { + await mkdir(tempRoot, { recursive: true }); + } + const fullPath = await join( + tempRoot, + `${Date.now()}-${index + 1}-${fileName}`, + ); + const buffer = await file.arrayBuffer(); + await writeFile(fullPath, new Uint8Array(buffer)); + + newContexts.push({ + label: + fileItems.length > 1 + ? `Pasted image ${index + 1}` + : "Pasted image", + filePath: fullPath, + selectedText: [ + `[Temporary pasted image: ${fullPath}]`, + "Use this image file as visual context for the user's message.", + ].join("\n"), + imageDataUrl: await readFileAsDataUrl(file), + isTemporary: true, + }); + } catch (err) { + log.error("Failed to save pasted image", { + fileName: file.name || "clipboard image", + error: String(err), + }); + } + continue; + } + // Generate a filename — use the original name or a timestamp-based name for screenshots let fileName = file.name; if (!fileName || fileName === "image.png") { @@ -450,11 +987,17 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const buffer = await file.arrayBuffer(); await writeFile(fullPath, new Uint8Array(buffer)); - // Determine if it's a text file - const isText = file.type.startsWith("text/"); - const content = isText - ? await file.text() - : `[Attached file: ${uniqueName} (${file.type})]`; + let content: string; + + if (isPdfPath(uniqueName) || file.type === "application/pdf") { + content = `[Attached file: ${uniqueName} (PDF)]`; + } else { + // Determine if it's a text file + const isText = file.type.startsWith("text/"); + content = isText + ? await file.text() + : `[Attached file: ${uniqueName} (${file.type})]`; + } newContexts.push({ label: `@${uniqueName}`, @@ -470,15 +1013,13 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { } if (newContexts.length > 0) { - // Refresh file list so the store knows about new files - await refreshFiles(); + if (newContexts.some((context) => context.label.startsWith("@"))) { + // Refresh only for files imported into the project tree. + await refreshFiles(); + } setPinnedContexts((prev) => { - const existingLabels = new Set(prev.map((c) => c.label)); - const unique = newContexts.filter( - (c) => !existingLabels.has(c.label), - ); - return [...prev, ...unique]; + return appendUniquePinnedContexts(prev, newContexts); }); } }, @@ -487,7 +1028,12 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const handleSend = useCallback(() => { const trimmed = input.trim(); - if (!trimmed || isStreaming) return; + if (!trimmed) return; + if (!providerSelectionReady) return; + if (imageCompatibilityError) { + setChatError(activeTabId, imageCompatibilityError); + return; + } // Resolve slash commands: if input starts with /command, find the command and substitute $ARGUMENTS // Skills (scope === "skill") are passed through as-is — Claude handles them via the Skill tool. @@ -511,17 +1057,25 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { setMentionQuery(null); setSlashQuery(null); slashSelectedRef.current = false; - // Send with pinned context override + + let contextOverride: PromptContextOverride | undefined; if (pinnedContexts.length > 0) { const combinedLabel = pinnedContexts.map((c) => c.label).join(", "); const combinedText = pinnedContexts .map((c) => c.selectedText) .join("\n\n---\n\n"); - sendPrompt(finalPrompt, { + contextOverride = { label: combinedLabel, filePath: pinnedContexts[0].filePath, selectedText: combinedText, - }); + temporaryFilePaths: temporaryFilePaths(pinnedContexts), + }; + } + + if (isStreaming) { + queueGuidance(activeTabId, finalPrompt, contextOverride); + } else if (contextOverride) { + sendPrompt(finalPrompt, contextOverride); } else { sendPrompt(finalPrompt); } @@ -529,9 +1083,40 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - // Clear pinned contexts after send + // Clear pinned contexts after send. Temporary files are removed by the + // completion event once the provider has finished with them. setPinnedContexts([]); - }, [input, isStreaming, sendPrompt, pinnedContexts, slashCommands]); + }, [ + activeTabId, + input, + isStreaming, + queueGuidance, + sendPrompt, + pinnedContexts, + imageCompatibilityError, + providerSelectionReady, + setChatError, + slashCommands, + ]); + + const handleGuideQueuedGuidance = useCallback( + (guidance: QueuedGuidance) => { + if (isStreaming) { + void forceQueuedGuidanceNow(activeTabId, guidance.id); + return; + } + + removeQueuedGuidance(activeTabId, guidance.id); + void sendPrompt(guidance.prompt, guidance.contextOverride); + }, + [ + activeTabId, + forceQueuedGuidanceNow, + isStreaming, + removeQueuedGuidance, + sendPrompt, + ], + ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -564,7 +1149,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); - selectMention(mentionFiles[mentionIndex]); + void selectMention(mentionFiles[mentionIndex]); return; } if (e.key === "Escape") { @@ -661,8 +1246,95 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { return () => document.removeEventListener("mousedown", handleClickOutside); }, [modelPickerOpen]); + const claudeModelOptions = [ + { + id: "sonnet" as const, + name: "Sonnet", + desc: "Fast, efficient for most tasks", + icon: , + }, + { + id: "opus" as const, + name: "Opus", + desc: "Most capable, complex reasoning", + icon: , + }, + { + id: "haiku" as const, + name: "Haiku", + desc: "Fastest, simple tasks", + icon: , + }, + { + id: "opusplan" as const, + name: "OpusPlan", + desc: "Opus for planning, Sonnet for execution", + icon: , + }, + ]; + const activeProviderModelOptions = selectedProviderCredential + ? Array.from( + new Set( + ( + providerModelOptions[selectedProviderCredential.id] ?? [ + selectedProviderCredential.model, + ] + ) + .map(modelInfoId) + .filter(Boolean), + ), + ).filter((model) => + isChatModelOption({ + label: selectedProviderCredential.label, + baseUrl: selectedProviderCredential.base_url, + model, + }), + ) + : []; + const activeProviderModelsLoading = + !!selectedProviderCredential && + providerModelLoadingId === selectedProviderCredential.id; + const activeProviderModelOptionsKey = activeProviderModelOptions.join("\0"); + + useLayoutEffect(() => { + if ( + !modelPickerOpen || + claudeProviderActive || + activeProviderModelsLoading + ) { + return; + } + + const list = providerModelListRef.current; + const item = providerModelItemRefs.current[directProviderModel]; + if (!list || !item) return; + + const itemTop = item.offsetTop - list.offsetTop; + const centeredTop = + itemTop - Math.max(0, (list.clientHeight - item.offsetHeight) / 2); + list.scrollTop = Math.max(0, centeredTop); + }, [ + activeProviderModelOptionsKey, + activeProviderModelsLoading, + claudeProviderActive, + directProviderModel, + modelPickerOpen, + selectedProviderCredential?.id, + ]); + return ( -
+
{/* / slash command picker — portal to body to escape all stacking contexts */} {slashQuery !== null && ( = ({ isOpen }) => { createPortal(
- {/* Models */} -
-
- Model -
- {[ - { - id: "sonnet" as const, - name: "Sonnet", - desc: "Fast, efficient for most tasks", - icon: , - }, - { - id: "opus" as const, - name: "Opus", - desc: "Most capable, complex reasoning", - icon: , - }, - { - id: "haiku" as const, - name: "Haiku", - desc: "Fastest, simple tasks", - icon: , - }, - { - id: "opusplan" as const, - name: "OpusPlan", - desc: "Opus for planning, Sonnet for execution", - icon: , - }, - ].map((m) => ( - - ))} -
- -
- - {/* Effort level */} -
-
- - Effort - - - {effortLevel === "low" - ? "Low" - : effortLevel === "medium" - ? "Medium" - : "High"} - -
-
- {(["low", "medium", "high"] as const).map((level) => ( +
+
+
+ Provider +
+ {showClaudeProvider && ( - ))} + )} + + {openAiCredentials.map((credential) => { + const active = + selectedProviderCredential?.id === credential.id; + const displayName = getProviderDisplayName({ + label: credential.label, + baseUrl: credential.base_url, + model: credential.model, + }); + const iconSrc = getProviderIconSrc({ + label: credential.label, + baseUrl: credential.base_url, + model: credential.model, + }); + const currentModel = + selectedProviderModels[credential.id] || credential.model; + + const isDeleting = deletingProviderId === credential.id; + const selectCredential = () => { + if (isDeleting) return; + setSelectedProviderCredentialId(credential.id); + }; + + return ( +
) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectCredential(); + } + }} + > + {iconSrc ? ( + + ) : ( + + )} +
+
+ {displayName} +
+
+ {currentModel} +
+
+
+ {active && } + +
+
+ ); + })} + +
+ +
+
+
+ Model +
+ {claudeProviderActive ? ( + claudeModelOptions.map((m) => ( + + )) + ) : selectedProviderCredential ? ( + <> + {activeProviderModelsLoading && ( +
+ Fetching models... +
+ )} + {activeProviderModelOptions.map((modelId) => ( + + ))} + {providerModelError && ( +
+ {providerModelError} +
+ )} + + ) : ( +
+ Select a provider +
+ )} +
+ {(claudeProviderActive || selectedProviderCredential) && ( +
+ +
+ )}
, document.body, )} + + + + Add AI Provider + + Configure Anthropic or another model provider for this project. + + + setProviderSetupOpen(false)} + onSaved={() => { + setProviderSetupOpen(false); + const setupState = useClaudeSetupStore.getState(); + const lastCredential = + setupState.openAiCredentials[ + setupState.openAiCredentials.length - 1 + ]; + setSelectedProviderCredentialId( + setupState.activeOpenAiCredentialId ?? + lastCredential?.id ?? + CLAUDE_CODE_PROVIDER_ID, + ); + setProviderModelOptions({}); + setProviderModelError(null); + }} + /> + + + + { + if (!open && !deletingProviderId) { + setProviderDeleteTarget(null); + setProviderDeleteError(null); + } + }} + > + + + Delete Provider + + Delete{" "} + + {providerDeleteTarget + ? getProviderDisplayName({ + label: providerDeleteTarget.label, + baseUrl: providerDeleteTarget.base_url, + model: providerDeleteTarget.model, + }) + : "this provider"} + {" "} + with model{" "} + + {providerDeleteTarget?.model || "unknown"} + + ? The API key will be removed from ClaudePrism. + + + {providerDeleteError && ( +

+ {providerDeleteError} +

+ )} + + + + +
+
+ {/* @ mention dropdown */} {slashQuery === null && mentionQuery !== null && mentionFiles.length > 0 && (
{mentionFiles.map((file, i) => { const parts = file.relativePath.split("/"); @@ -804,7 +1725,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { )} onMouseDown={(e) => { e.preventDefault(); // prevent textarea blur - selectMention(file); + void selectMention(file); }} onMouseEnter={() => setMentionIndex(i)} > @@ -823,13 +1744,57 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
+ {visibleQueuedGuidance.length > 0 && ( +
+ {visibleQueuedGuidance.map((guidance) => { + const displayText = formatGuidanceText(guidance); + return ( +
+ + + {displayText} + + + +
+ ); + })} +
+ )} + {/* Pinned context chips */} {pinnedContexts.length > 0 && ( -
+
{pinnedContexts.map((ctx, i) => ctx.imageDataUrl ? (
= ({ isOpen }) => { />
- {isStreaming ? ( +
- - - ) : ( - void cancelExecution(activeTabId) + : handleSend + } + disabled={!isStreaming && (!hasInput || !providerSelectionReady)} > - + {isStreaming && !hasInput ? ( + + ) : ( + + )} - )} +
diff --git a/apps/desktop/src/components/claude-chat/chat-messages.tsx b/apps/desktop/src/components/claude-chat/chat-messages.tsx index 76c0c71..2d65983 100644 --- a/apps/desktop/src/components/claude-chat/chat-messages.tsx +++ b/apps/desktop/src/components/claude-chat/chat-messages.tsx @@ -1,61 +1,125 @@ import { type FC, memo, useEffect, useMemo, useRef, useState } from "react"; -import { AlertCircleIcon } from "lucide-react"; +import { + AlertCircleIcon, + CheckIcon, + CopyIcon, + CornerDownRightIcon, +} from "lucide-react"; import { useClaudeChatStore, type ClaudeStreamMessage, type ContentBlock, + type QueuedGuidance, } from "@/stores/claude-chat-store"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; import { MarkdownRenderer } from "./markdown-renderer"; import { ThinkingWidget, ToolWidget } from "./tool-widgets"; // ─── Streaming Indicator (isolated to prevent re-render storms) ─── -const StreamingIndicator: FC = memo(() => { - const [elapsed, setElapsed] = useState(0); - const startRef = useRef(Date.now()); +const StreamingIndicator: FC<{ startedAt: number | null }> = memo( + ({ startedAt }) => { + const calculateElapsed = () => + startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0; - useEffect(() => { - startRef.current = Date.now(); - setElapsed(0); - const timer = setInterval(() => { - setElapsed(Math.floor((Date.now() - startRef.current) / 1000)); - }, 1000); - return () => clearInterval(timer); - }, []); + const [elapsed, setElapsed] = useState(calculateElapsed); + + useEffect(() => { + setElapsed(calculateElapsed()); + const timer = setInterval(() => { + setElapsed(calculateElapsed()); + }, 1000); + return () => clearInterval(timer); + }, [startedAt]); + + return ( +
+
+ + + +
+ + Thinking... + {elapsed >= 3 && ( + + {elapsed}s + + )} + +
+ ); + }, +); + +const EMPTY_PENDING_GUIDANCE: QueuedGuidance[] = []; +const THREAD_MAX_WIDTH = "max-w-[44rem]"; + +const MessageActions: FC<{ + text: string; + align?: "left" | "right"; +}> = ({ text, align = "left" }) => { + const [copied, setCopied] = useState(false); + const canCopy = text.trim().length > 0; + + const handleCopy = async () => { + if (!canCopy) return; + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + + if (!canCopy) return null; return ( -
-
- - - -
- - Thinking... - {elapsed >= 3 && ( - - {elapsed}s - +
+ + {copied ? ( + + ) : ( + )} - +
); -}); +}; // ─── Chat Messages (main component) ─── export const ChatMessages: FC = () => { const messages = useClaudeChatStore((s) => s.messages) ?? []; const isStreaming = useClaudeChatStore((s) => s.isStreaming); + const streamingStartedAt = useClaudeChatStore((s) => s.streamingStartedAt); + const queuedGuidance = + useClaudeChatStore( + (s) => s.tabs.find((tab) => tab.id === s.activeTabId)?.queuedGuidance, + ) ?? EMPTY_PENDING_GUIDANCE; + const pendingGuidance = useMemo( + () => queuedGuidance.filter((guidance) => guidance.displayedInChat), + [queuedGuidance], + ); const viewportRef = useRef(null); const shouldAutoScrollRef = useRef(true); const userHasScrolledRef = useRef(false); @@ -120,7 +184,7 @@ export const ChatMessages: FC = () => { behavior: "smooth", }); } - }, [displayMessages]); + }, [displayMessages, pendingGuidance]); // Reset auto-scroll when streaming stops useEffect(() => { @@ -148,19 +212,36 @@ export const ChatMessages: FC = () => {
- {displayMessages.length === 0 && !isStreaming && ( -
- Ask Claude about your LaTeX document... + {displayMessages.length === 0 && + pendingGuidance.length === 0 && + !isStreaming && ( +
+ Ask Claude about your LaTeX document... +
+ )} + + {displayMessages.map((msg, idx) => ( +
+ +
+ ))} + + {isStreaming && ( +
+
)} - {displayMessages.map((msg, idx) => ( - + {pendingGuidance.map((guidance) => ( +
+ +
))} - - {isStreaming && }
); }; @@ -198,10 +279,15 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { if (!textContent) return null; - // Parse leading @file:line:col or ~@file:line context reference - const contextMatch = textContent.match(/^(~?@[^\n]+)\n([\s\S]*)$/); - const contextLabel = contextMatch?.[1] ?? null; - const bodyText = contextMatch ? contextMatch[2] : textContent; + const firstLineMatch = textContent.match(/^([^\n]+)\n([\s\S]*)$/); + const firstLine = firstLineMatch?.[1]?.trim() ?? ""; + const hasContextLabel = + firstLine.startsWith("@") || + firstLine.startsWith("~@") || + /^Pasted image(?: \d+)?(?:, Pasted image(?: \d+)?)*$/.test(firstLine); + const contextLabel = hasContextLabel ? firstLine : null; + const bodyText = + hasContextLabel && firstLineMatch ? firstLineMatch[2] : textContent; // Parse error block patterns for styled rendering: // Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt" @@ -223,27 +309,34 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { errors: { message: string; location?: string }[], prompt: string, ) => ( -
-
-
-
{title}
-
- {errors.map((e, i) => ( -
- - - {e.message} - - {e.location && ( - - {e.location} +
+
+
+
+
+ {title} +
+
+ {errors.map((e, i) => ( +
+ + + {e.message} - )} -
- ))} + {e.location && ( + + {e.location} + + )} +
+ ))} +
+ {prompt}
- {prompt} +
+
+
); @@ -287,18 +380,23 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { } return ( -
-
- {contextLabel && ( - - {contextLabel} - - )} - {contextLabel && bodyText &&
} - +
+
+
+ {contextLabel && ( + + {contextLabel} + + )} + {contextLabel && bodyText &&
} + +
+
+
+
); @@ -306,6 +404,40 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { // ─── Assistant Message ─── +const PendingGuidanceMessage: FC<{ guidance: QueuedGuidance }> = ({ + guidance, +}) => { + const contextLabel = guidance.contextOverride?.label ?? null; + const copyText = contextLabel + ? `${contextLabel}\n${guidance.prompt}` + : guidance.prompt; + + return ( +
+
+
+ {contextLabel && ( + + {contextLabel} + + )} + {contextLabel && guidance.prompt &&
} +
+ + +
+
+
+
+ +
+
+ ); +}; + const AssistantMessage: FC<{ message: ClaudeStreamMessage; toolResultMap: Map; @@ -322,19 +454,15 @@ const AssistantMessage: FC<{ if (!hasRenderableContent) return null; + const copyText = content + .filter((block) => block.type === "text" && block.text) + .map((block) => block.text) + .join("\n\n"); + return ( -
-
+
+
{content.map((block, idx) => { - if (block.type === "thinking" && block.thinking) { - return ( - - ); - } if (block.type === "text" && block.text) { return ( ; } + if (block.type === "thinking" && block.thinking) { + return ( + + ); + } return null; })}
+
+ +
); }; @@ -364,8 +504,8 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { if (!resultText) return null; return ( -
-
+
+
{isError ? (
{resultText} @@ -377,6 +517,9 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { /> )}
+
+ +
{message.cost_usd != null && (
Cost: ${message.cost_usd.toFixed(4)} diff --git a/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx b/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx index ac3c78c..d38eaf9 100644 --- a/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx +++ b/apps/desktop/src/components/claude-chat/chat-tab-bar.tsx @@ -95,11 +95,11 @@ export function ChatTabBar() { /> ))}
-
+
+ + + - - - + Sessions - + + New Chat + + + + + {isLoading ? ( +
+ +
+ ) : sessions.length === 0 ? ( +
+ No previous sessions +
+ ) : ( + sessions.map((session) => { + const sessionIsStreaming = streamingSessionIds.has( + session.session_id, + ); + return ( + handleSelectSession(session)} + disabled={deletingId === session.session_id} + className="group flex items-start gap-2" + > +
+ {session.title} + + {formatRelativeTime(session.last_modified)} + +
+
+ {sessionIsStreaming ? ( + + ) : ( + session.session_id === sessionId && ( + + ) + )} + +
+
+ ); + }) + )} +
+ + + { + if (!open && !deletingId) { + setDeleteTarget(null); + setDeleteError(null); + } + }} > - Sessions - - - - New Chat - - - - - {isLoading ? ( -
- -
- ) : sessions.length === 0 ? ( -
- No previous sessions -
- ) : ( - sessions.map((session) => ( - handleSelectSession(session.session_id)} - disabled={isStreaming} - className="flex items-start gap-2" + + + Delete Chat + + Delete "{deleteTarget?.title || "this session"}" from this + project? + + + {deleteError && ( +

+ {deleteError} +

+ )} + + + + +
+
+ ); } diff --git a/apps/desktop/src/components/claude-chat/tool-widgets.tsx b/apps/desktop/src/components/claude-chat/tool-widgets.tsx index 067deff..ce3d644 100644 --- a/apps/desktop/src/components/claude-chat/tool-widgets.tsx +++ b/apps/desktop/src/components/claude-chat/tool-widgets.tsx @@ -1,6 +1,6 @@ import { type FC, useState } from "react"; import { - BotIcon, + BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -37,12 +37,18 @@ export const ToolWidget: FC = ({ toolUse, toolResult }) => { return ; if (name === "bash") return ; + if (name === "powershell" || name === "pwsh") + return ( + + ); if (name === "glob") return ; if (name === "grep") return ; if (name === "askuserquestion") return ; + if (name === "exitplanmode") + return ; if (name === "todowrite") return ; @@ -160,26 +166,27 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ // ─── Bash Widget ─── -const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ - input, - result, -}) => { +const BashWidget: FC<{ + input: any; + result?: ContentBlock; + prefix?: string; +}> = ({ input, result, prefix = "$" }) => { const [expanded, setExpanded] = useState(false); const command = input?.command || input?.description || ""; const resultContent = typeof result?.content === "string" ? result.content : ""; return ( -
+
{expanded && resultContent && (
-
+          
             {truncate(resultContent, 2000)}
           
@@ -359,6 +366,78 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ ); }; +// ExitPlanMode Widget + +const ExitPlanModeWidget: FC<{ input: any; result?: ContentBlock }> = ({ + input, + result, +}) => { + const [answered, setAnswered] = useState(false); + const isStreaming = useClaudeChatStore((s) => s.isStreaming); + const needsApproval = + !answered && !isStreaming && (!result || result.is_error); + const plan = input?.plan || input?.content || ""; + + const sendPlanResponse = (text: string) => { + const { sendPrompt, isStreaming } = useClaudeChatStore.getState(); + if (isStreaming) return; + setAnswered(true); + sendPrompt(text); + }; + + return ( +
+
+ + + {needsApproval + ? "Plan needs approval" + : answered + ? "Plan response sent" + : "Plan handled"} + +
+ {plan && ( +
+
+            {plan}
+          
+
+ )} + {needsApproval && ( +
+ + +
+ )} +
+ ); +}; + // ─── TodoWrite Widget ─── const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ @@ -484,9 +563,8 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10" >
-
- - +
+
Thinking... diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx index b35570a..40e1dff 100644 --- a/apps/desktop/src/components/claude-setup.tsx +++ b/apps/desktop/src/components/claude-setup.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { DownloadIcon, @@ -13,15 +13,275 @@ import { ChevronRightIcon, GitBranchIcon, ExternalLinkIcon, + KeyRoundIcon, + Trash2Icon, } from "lucide-react"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useClaudeSetupStore, type StepInfo, } from "@/stores/claude-setup-store"; +import { + getProviderDisplayName, + getProviderIconSrc, +} from "@/lib/provider-icons"; +import { ModelCapabilityBadges } from "@/components/model-capability-badges"; import { cn } from "@/lib/utils"; +type OpenAICompatiblePreset = { + id: string; + label: string; + baseUrl: string; + model: string; + note: string; + apiKeyOptional?: boolean; +}; + +type ClaudeCompatiblePreset = { + id: string; + label: string; + baseUrl: string; + note: string; +}; + +type ModelProviderCard = { + id: string; + label: string; + provider: "claude-code" | "openai-compatible"; + baseUrl: string; + model: string; + badge: string; + note: string; + apiKeyOptional?: boolean; +}; + +const CLAUDE_COMPATIBLE_PRESETS: ClaudeCompatiblePreset[] = [ + { + id: "modelgate-web", + label: "ModelGate Claude (Web)", + baseUrl: "https://mg.aid.pub/claude-proxy", + note: "Use a ModelGate web API key with the Claude proxy endpoint.", + }, +]; + +const OPENAI_COMPATIBLE_PRESETS: OpenAICompatiblePreset[] = [ + { + id: "openai", + label: "OpenAI", + baseUrl: "https://api.openai.com", + model: "", + note: "OpenAI chat completions endpoint.", + }, + { + id: "qwen", + label: "Qwen", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + model: "", + note: "Qwen Anthropic-compatible endpoint for Claude Code.", + }, + { + id: "deepseek", + label: "DeepSeek", + baseUrl: "https://api.deepseek.com/anthropic", + model: "", + note: "DeepSeek Anthropic-compatible endpoint for Claude Code.", + }, + { + id: "moonshot", + label: "Moonshot / Kimi", + baseUrl: "https://api.moonshot.ai/anthropic", + model: "", + note: "Kimi Anthropic-compatible endpoint for Claude Code.", + }, + { + id: "glm", + label: "GLM (BigModel)", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + model: "", + note: "Zhipu BigModel chat completions endpoint.", + }, + { + id: "ollama", + label: "Ollama", + baseUrl: "http://localhost:11434/v1", + model: "", + note: "Local Ollama OpenAI-compatible endpoint.", + apiKeyOptional: true, + }, + { + id: "gemini", + label: "Gemini OpenAI", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + model: "", + note: "Google Gemini OpenAI-compatible endpoint.", + }, +]; + +const OPENAI_PROVIDER_CARDS: ModelProviderCard[] = [ + ...OPENAI_COMPATIBLE_PRESETS.map((preset) => ({ + ...preset, + provider: "openai-compatible" as const, + badge: preset.label + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(), + })), +]; + +const CLAUDE_PROVIDER_CARDS: ModelProviderCard[] = [ + { + id: "anthropic-direct", + label: "Anthropic", + provider: "claude-code", + baseUrl: "", + model: "", + badge: "A", + note: "Use a direct Anthropic API key.", + }, + ...CLAUDE_COMPATIBLE_PRESETS.map((preset) => ({ + ...preset, + provider: "claude-code" as const, + model: "", + badge: "MG", + })), +]; + +const OPENAI_DEFAULT_PRESET_ID = OPENAI_PROVIDER_CARDS[0]?.id ?? "openai"; +const DEEPSEEK_ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic"; +const QWEN_ANTHROPIC_BASE_URL = "https://dashscope.aliyuncs.com/apps/anthropic"; +const MOONSHOT_ANTHROPIC_BASE_URL = "https://api.moonshot.ai/anthropic"; +const MOONSHOT_OFFICIAL_ORIGIN = "https://api.moonshot.ai"; + +function deepseekOrigin(url: string) { + const trimmed = url.trim(); + const match = trimmed.match(/^(https?:\/\/api\.deepseek\.com)(?:\/|$)/i); + return match?.[1] ?? null; +} + +function qwenOrigin(url: string) { + const trimmed = url.trim(); + const match = trimmed.match( + /^(https?:\/\/dashscope(?:-intl)?\.aliyuncs\.com)(?:\/|$)/i, + ); + return match?.[1] ?? null; +} + +function moonshotOrigin(url: string) { + const trimmed = url.trim(); + const match = trimmed.match( + /^(https?:\/\/api\.moonshot\.(?:cn|ai))(?:\/|$)/i, + ); + return match?.[1] ?? null; +} + +function canonicalOpenAiCompatibleBaseUrl( + url: string, + presetId?: string | null, +) { + const trimmed = url.trim(); + const origin = deepseekOrigin(trimmed); + if ( + origin && + (presetId === "deepseek" || !trimmed.toLowerCase().includes("/anthropic")) + ) { + const lower = trimmed.toLowerCase(); + const anthropicIndex = lower.indexOf("/anthropic"); + if (anthropicIndex >= 0) { + return `${trimmed.slice(0, anthropicIndex)}/anthropic`; + } + return `${origin}/anthropic`; + } + + const qwenBaseOrigin = qwenOrigin(trimmed); + if ( + qwenBaseOrigin && + (presetId === "qwen" || + trimmed.toLowerCase().includes("/apps/anthropic") || + trimmed.toLowerCase().includes("/compatible-mode/") || + normalizeOriginOnlyUrl(trimmed) === + normalizeOriginOnlyUrl(qwenBaseOrigin)) + ) { + const lower = trimmed.toLowerCase(); + const anthropicIndex = lower.indexOf("/apps/anthropic"); + if (anthropicIndex >= 0) { + return `${trimmed.slice(0, anthropicIndex)}/apps/anthropic`; + } + return `${qwenBaseOrigin}/apps/anthropic`; + } + + const moonshotBaseOrigin = moonshotOrigin(trimmed); + if ( + moonshotBaseOrigin && + (presetId === "moonshot" || + trimmed.toLowerCase().includes("/anthropic") || + trimmed.toLowerCase().includes("/v1") || + normalizeOriginOnlyUrl(trimmed) === + normalizeOriginOnlyUrl(moonshotBaseOrigin)) + ) { + const lower = trimmed.toLowerCase(); + const anthropicIndex = lower.indexOf("/anthropic"); + if (anthropicIndex >= 0) { + return `${MOONSHOT_OFFICIAL_ORIGIN}/anthropic`; + } + return `${MOONSHOT_OFFICIAL_ORIGIN}/anthropic`; + } + + return trimmed; +} + +function normalizeOriginOnlyUrl(value: string) { + return value.trim().replace(/\/+$/, "").toLowerCase(); +} + +function isNativeAnthropicPreset(cardId?: string | null) { + return cardId === "deepseek" || cardId === "qwen" || cardId === "moonshot"; +} + +function normalizePresetBaseUrl(url: string) { + return canonicalOpenAiCompatibleBaseUrl(url) + .replace(/\/chat\/completions$/i, "") + .replace(/\/+$/, "") + .toLowerCase(); +} + +function findOpenAiPresetIdForBaseUrl(baseUrl?: string | null) { + const normalized = normalizePresetBaseUrl(baseUrl ?? ""); + if (!normalized) return null; + + return ( + OPENAI_COMPATIBLE_PRESETS.find( + (preset) => normalizePresetBaseUrl(preset.baseUrl) === normalized, + )?.id ?? null + ); +} + +function openAiPresetIdForBaseUrl(baseUrl?: string | null) { + return findOpenAiPresetIdForBaseUrl(baseUrl) ?? OPENAI_DEFAULT_PRESET_ID; +} + +function findClaudePresetIdForBaseUrl(baseUrl?: string | null) { + const normalized = normalizePresetBaseUrl(baseUrl ?? ""); + if (!normalized) return null; + + return ( + CLAUDE_COMPATIBLE_PRESETS.find( + (preset) => normalizePresetBaseUrl(preset.baseUrl) === normalized, + )?.id ?? null + ); +} + // ─── Event Hooks ─── function useInstallEvents() { @@ -71,23 +331,13 @@ function useInstallEvents() { useClaudeSetupStore.getState()._appendInstallLog(event.payload); }); - const unlistenComplete = await listen( - "install-complete", - (event) => { - if (cancelled) return; - clearTimeout(timer); - useClaudeSetupStore.getState()._finishInstall(event.payload); - }, - ); - if (cancelled) { unlistenOutput(); unlistenError(); - unlistenComplete(); return; } - unlisteners.push(unlistenOutput, unlistenError, unlistenComplete); + unlisteners.push(unlistenOutput, unlistenError); })(); return () => { @@ -239,15 +489,48 @@ function InstallLogOutput() { // ─── Main Component ─── -export function ClaudeSetup() { +interface ClaudeSetupProps { + variant?: "default" | "provider-dialog" | "embedded"; + onSaved?: () => void; + onCancel?: () => void; +} + +export function ClaudeSetup({ + variant = "default", + onSaved, + onCancel, +}: ClaudeSetupProps = {}) { + const [provider, setProvider] = useState<"claude-code" | "openai-compatible">( + "claude-code", + ); + const [providerPreset, setProviderPreset] = useState("anthropic-direct"); + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [model, setModel] = useState(""); + const [modelOptions, setModelOptions] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + const [modelFetchError, setModelFetchError] = useState(null); + const [isEditingProvider, setIsEditingProvider] = useState(false); const status = useClaudeSetupStore((s) => s.status); const isInstalling = useClaudeSetupStore((s) => s.isInstalling); const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn); + const isSavingApiKey = useClaudeSetupStore((s) => s.isSavingApiKey); + const isClearingApiKey = useClaudeSetupStore((s) => s.isClearingApiKey); const error = useClaudeSetupStore((s) => s.error); const version = useClaudeSetupStore((s) => s.version); + const providerKind = useClaudeSetupStore((s) => s.providerKind); const accountEmail = useClaudeSetupStore((s) => s.accountEmail); + const providerModel = useClaudeSetupStore((s) => s.providerModel); + const providerBaseUrl = useClaudeSetupStore((s) => s.providerBaseUrl); + const claudeProviderConfigured = useClaudeSetupStore( + (s) => s.claudeProviderConfigured, + ); + const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials); const install = useClaudeSetupStore((s) => s.install); const login = useClaudeSetupStore((s) => s.login); + const saveApiKey = useClaudeSetupStore((s) => s.saveApiKey); + const clearApiKey = useClaudeSetupStore((s) => s.clearApiKey); + const fetchProviderModels = useClaudeSetupStore((s) => s.fetchProviderModels); const checkStatus = useClaudeSetupStore((s) => s.checkStatus); const installSteps = useClaudeSetupStore((s) => s.installSteps); const loginSteps = useClaudeSetupStore((s) => s.loginSteps); @@ -255,9 +538,465 @@ export function ClaudeSetup() { useInstallEvents(); useLoginEvents(); + const isEmbedded = variant === "embedded"; + const setupSurfaceClass = ( + tone: "default" | "error" | "warning" = "default", + ) => + cn( + "flex w-full flex-col gap-3", + isEmbedded + ? "px-4 py-3" + : tone === "error" + ? "rounded-xl border border-destructive/30 bg-destructive/5 px-5 py-4" + : tone === "warning" + ? "rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4" + : "rounded-xl border border-border bg-muted/30 px-5 py-4", + ); + + const handleSaveApiKey = async ( + selectedProvider: "claude-code" | "openai-compatible" = provider, + credentialLabel?: string, + ) => { + const savedBaseUrl = + selectedProvider === "openai-compatible" + ? canonicalOpenAiCompatibleBaseUrl(baseUrl, providerPreset) + : baseUrl.trim(); + const savedPreset = + selectedProvider === "openai-compatible" + ? openAiPresetIdForBaseUrl(savedBaseUrl) + : "anthropic-direct"; + const success = await saveApiKey( + apiKey, + savedBaseUrl, + selectedProvider, + model, + credentialLabel, + ); + if (success) { + setApiKey(""); + setBaseUrl(""); + setModel(""); + setModelOptions([]); + setModelFetchError(null); + setProviderPreset(savedPreset); + setIsEditingProvider(false); + onSaved?.(); + } + }; + + const resetProviderForm = () => { + setIsEditingProvider(false); + setApiKey(""); + setBaseUrl(""); + setModel(""); + setModelOptions([]); + setModelFetchError(null); + setProviderPreset("anthropic-direct"); + }; + + const beginProviderEdit = (isDirectProvider: boolean) => { + const nextBaseUrl = isDirectProvider + ? canonicalOpenAiCompatibleBaseUrl(providerBaseUrl || "") + : ""; + setProvider(isDirectProvider ? "openai-compatible" : "claude-code"); + setProviderPreset( + isDirectProvider + ? openAiPresetIdForBaseUrl(nextBaseUrl) + : "anthropic-direct", + ); + setApiKey(""); + setBaseUrl(nextBaseUrl); + setModel(isDirectProvider ? providerModel || "" : ""); + setModelOptions([]); + setModelFetchError(null); + setIsEditingProvider(true); + }; + + const handleClearApiKey = async () => { + const success = await clearApiKey(); + if (success) { + setApiKey(""); + setBaseUrl(""); + setModel(""); + setModelOptions([]); + setModelFetchError(null); + setProviderPreset("anthropic-direct"); + setIsEditingProvider(false); + } + }; + + const selectProviderCard = (card: ModelProviderCard) => { + setProvider(card.provider); + setProviderPreset(card.id); + setBaseUrl(card.baseUrl); + setModel(card.model); + setModelOptions(card.model ? [card.model] : []); + setModelFetchError(null); + }; + + const handleFetchModels = async () => { + setIsFetchingModels(true); + setModelFetchError(null); + try { + const models = await fetchProviderModels(apiKey, baseUrl); + setModelOptions(models); + if (!models.includes(model)) { + setModel(models[0] ?? ""); + } + } catch (err: any) { + setModelOptions([]); + setModelFetchError(err?.message || String(err)); + } finally { + setIsFetchingModels(false); + } + }; + + const renderApiKeyForm = ({ + forceOpenAiCompatible = false, + allowBrowserSignIn = false, + }: { + forceOpenAiCompatible?: boolean; + allowBrowserSignIn?: boolean; + } = {}) => { + const selectedProvider = forceOpenAiCompatible + ? "openai-compatible" + : provider; + const providerCards = forceOpenAiCompatible + ? OPENAI_PROVIDER_CARDS + : [...CLAUDE_PROVIDER_CARDS, ...OPENAI_PROVIDER_CARDS]; + const providerCardIds = new Set(providerCards.map((card) => card.id)); + const fallbackCardId = + selectedProvider === "openai-compatible" + ? OPENAI_DEFAULT_PRESET_ID + : "anthropic-direct"; + const activeCardId = providerCardIds.has(providerPreset) + ? providerPreset + : fallbackCardId; + const activeCard = providerCards.find((card) => card.id === activeCardId); + const apiKeyOptional = + selectedProvider === "openai-compatible" && !!activeCard?.apiKeyOptional; + const apiKeyRequired = !apiKeyOptional; + const showBrowserSignIn = + allowBrowserSignIn && + selectedProvider === "claude-code" && + activeCardId === "anthropic-direct"; + + return ( + <> +
{ + event.preventDefault(); + handleSaveApiKey(selectedProvider, activeCard?.label); + }} + > +
+ +
+ {providerCards.map((card) => { + const iconSrc = getProviderIconSrc(card); + const active = activeCardId === card.id; + + return ( + + ); + })} +
+
+ +
+ + { + setApiKey(event.target.value); + setModelOptions([]); + setModelFetchError(null); + }} + disabled={isSavingApiKey} + autoComplete="off" + /> +

+ {selectedProvider === "openai-compatible" + ? apiKeyOptional + ? "Ollama runs locally and normally does not require an API key." + : "Use the API key from your model provider." + : "Anthropic keys start with sk-ant-. Claude-compatible proxies can use their own key format."} +

+
+ +
+ + { + const nextUrl = event.target.value; + const matchingPreset = findOpenAiPresetIdForBaseUrl(nextUrl); + const matchingClaudePreset = + findClaudePresetIdForBaseUrl(nextUrl); + setBaseUrl(nextUrl); + setModelOptions([]); + setModelFetchError(null); + if (selectedProvider === "openai-compatible") { + if (matchingPreset) { + setProviderPreset(matchingPreset); + if (isNativeAnthropicPreset(matchingPreset)) { + const canonicalUrl = canonicalOpenAiCompatibleBaseUrl( + nextUrl, + matchingPreset, + ); + if (canonicalUrl !== nextUrl.trim()) { + setBaseUrl(canonicalUrl); + } + } + } + } else { + if (matchingClaudePreset) { + setProviderPreset(matchingClaudePreset); + } else if (!nextUrl.trim()) { + setProviderPreset("anthropic-direct"); + } + } + }} + disabled={isSavingApiKey} + autoComplete="off" + /> +

+ {selectedProvider === "openai-compatible" + ? activeCardId === "deepseek" + ? "DeepSeek runs through its native Anthropic-compatible Claude Code route." + : activeCardId === "qwen" + ? "Qwen runs through its native Anthropic-compatible Claude Code route." + : activeCardId === "moonshot" + ? "Kimi runs through its native Anthropic-compatible Claude Code route." + : "Use either the API root or a full /chat/completions URL." + : "Leave blank for Anthropic direct API."} +

+
+ + {selectedProvider === "openai-compatible" && ( +
+
+ + +
+ {modelOptions.length > 0 ? ( + + ) : ( + { + setModel(event.target.value); + }} + disabled={isSavingApiKey} + autoComplete="off" + /> + )} + {modelFetchError && ( +

+ {modelFetchError} +

+ )} +

+ {activeCardId === "deepseek" + ? "Fetches DeepSeek models from the matching provider model endpoint." + : activeCardId === "qwen" + ? "Fetches Qwen models from the matching DashScope model endpoint." + : activeCardId === "moonshot" + ? "Fetches Kimi models from the matching Moonshot model endpoint." + : "Fetches the provider's real /models list when available."} +

+
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + {showBrowserSignIn && ( + <> +
+
+ or +
+
+ + + + )} + + ); + }; + if (status === "checking") { return ( -
+
Checking Claude Code... @@ -266,16 +1005,276 @@ export function ClaudeSetup() { ); } - if (status === "ready") { + if (variant === "provider-dialog" && status === "missing-git") { return ( -
- -
-

Claude Code Ready

-

- {[version, accountEmail].filter(Boolean).join(" · ")} -

+
+
+ +
+

Install Git first

+

+ Claude Code needs Git for Windows before providers can be added. +

+
+ + +
+ ); + } + + if (variant === "provider-dialog" && status === "not-installed") { + return ( +
+
+ +
+

Install Claude Code first

+

+ AI providers can be configured after the Claude Code CLI is + installed. +

+
+
+ +
+ ); + } + + if (variant === "provider-dialog") { + return ( +
+ {renderApiKeyForm({ allowBrowserSignIn: true })} + +
+ ); + } + + if (status === "ready") { + const isDirectProvider = providerKind === "openai-compatible"; + const openAiProviderCount = Math.max( + openAiCredentials.length, + isDirectProvider && (providerModel || providerBaseUrl) ? 1 : 0, + ); + const includesClaudeProvider = + claudeProviderConfigured || !isDirectProvider; + const configuredProviderCount = + openAiProviderCount + (includesClaudeProvider ? 1 : 0); + const readyDetail = [ + `${configuredProviderCount} provider${configuredProviderCount === 1 ? "" : "s"} configured`, + version ? `Claude Code ${version}` : null, + !isDirectProvider && accountEmail ? accountEmail : null, + ] + .filter(Boolean) + .join(" / "); + const claudeProviderIconSrc = getProviderIconSrc({ label: "Anthropic" }); + + if (isEditingProvider) { + return ( +
+
+ +
+

+ {isDirectProvider ? "Update AI Provider" : "Update Claude Code"} +

+

+ {readyDetail} +

+
+
+ + {renderApiKeyForm({ allowBrowserSignIn: !isDirectProvider })} + +
+ + +
+
+ ); + } + + return ( +
+
+
+ +
+
+
+ + AI Providers + + + {configuredProviderCount} + +
+

+ {readyDetail} +

+
+ + +
+ + {(includesClaudeProvider || openAiCredentials.length > 0) && ( +
+ {includesClaudeProvider && ( +
+ {claudeProviderIconSrc ? ( + + ) : ( + + )} +
+
+ + Anthropic / Claude Code + + + {accountEmail || "Claude Code"} + +
+
+
+ )} + {openAiCredentials.map((credential) => { + const displayName = getProviderDisplayName({ + label: credential.label, + baseUrl: credential.base_url, + model: credential.model, + }); + const iconSrc = getProviderIconSrc({ + label: credential.label, + baseUrl: credential.base_url, + model: credential.model, + }); + + return ( +
+ {iconSrc ? ( + + ) : ( + + )} +
+
+ + {displayName} + + + {credential.model} + +
+
+
+ +
+
+ ); + })} +
+ )}
); } @@ -283,7 +1282,7 @@ export function ClaudeSetup() { // Installation in progress if (isInstalling) { return ( -
+

Installing Claude Code

@@ -303,7 +1302,7 @@ export function ClaudeSetup() { // Login in progress if (isLoggingIn) { return ( -
+

Signing in to Claude

@@ -326,7 +1325,7 @@ export function ClaudeSetup() { const hasInstallSteps = installSteps.length > 0; return ( -
+

@@ -380,14 +1379,14 @@ export function ClaudeSetup() { if (status === "missing-git") { return ( -

+
-

Git for Windows Required

+

Install Git first

- Claude Code needs Git for Windows (git-bash) to work. Please - install it first, then click "I've installed Git". + Git for Windows is required before Claude Code can be installed + and providers can be configured.

@@ -417,17 +1416,22 @@ export function ClaudeSetup() { if (status === "not-installed") { return ( -
+
- +
-

Claude Code Required

+

Install Claude Code first

- ClaudePrism needs Claude Code CLI to power AI features. + Provider keys can be added after the Claude Code CLI is installed.

- @@ -440,13 +1444,13 @@ export function ClaudeSetup() { if (status === "not-authenticated") { return ( -
+
- +
-

Sign in to Claude

+

Connect Claude

- Authenticate with your Anthropic account to continue. + Use an Anthropic key, an external API proxy, or browser sign-in.

@@ -455,10 +1459,8 @@ export function ClaudeSetup() { Claude Code {version} installed

)} - + + {renderApiKeyForm({ allowBrowserSignIn: true })}
); } diff --git a/apps/desktop/src/components/environment-onboarding.tsx b/apps/desktop/src/components/environment-onboarding.tsx new file mode 100644 index 0000000..f892958 --- /dev/null +++ b/apps/desktop/src/components/environment-onboarding.tsx @@ -0,0 +1,509 @@ +import { + type ComponentType, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { open as shellOpen } from "@tauri-apps/plugin-shell"; +import { + AlertCircleIcon, + CheckCircle2Icon, + CircleIcon, + DownloadIcon, + FlaskConicalIcon, + GitBranchIcon, + KeyRoundIcon, + Loader2Icon, + RefreshCwIcon, + TerminalIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ClaudeSetup } from "@/components/claude-setup"; +import { useClaudeSetupStore } from "@/stores/claude-setup-store"; +import { useUvSetupStore } from "@/stores/uv-setup-store"; +import { cn } from "@/lib/utils"; + +type SetupItemState = "ready" | "loading" | "blocked" | "error"; + +interface SkillsStatus { + installed: boolean; + skill_count: number; + location: string; +} + +export function EnvironmentOnboarding() { + const [initialCheckComplete, setInitialCheckComplete] = useState(false); + const keepOpenDuringCheckRef = useRef(false); + const [hasOpenedForSetup, setHasOpenedForSetup] = useState(false); + const [completedDismissed, setCompletedDismissed] = useState(false); + const [providerDialogOpen, setProviderDialogOpen] = useState(false); + const [skillsStatus, setSkillsStatus] = useState(null); + const [skillsChecking, setSkillsChecking] = useState(true); + const [skillsError, setSkillsError] = useState(null); + const [skillsDialogOpen, setSkillsDialogOpen] = useState(false); + const [SkillsOnboardingComponent, setSkillsOnboardingComponent] = + useState void }> | null>(null); + + const claudeStatus = useClaudeSetupStore((s) => s.status); + const claudeVersion = useClaudeSetupStore((s) => s.version); + const claudeError = useClaudeSetupStore((s) => s.error); + const isClaudeInstalling = useClaudeSetupStore((s) => s.isInstalling); + const providerKind = useClaudeSetupStore((s) => s.providerKind); + const claudeProviderConfigured = useClaudeSetupStore( + (s) => s.claudeProviderConfigured, + ); + const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials); + const checkClaudeStatus = useClaudeSetupStore((s) => s.checkStatus); + const installClaude = useClaudeSetupStore((s) => s.install); + + const uvStatus = useUvSetupStore((s) => s.status); + const uvVersion = useUvSetupStore((s) => s.version); + const uvError = useUvSetupStore((s) => s.error); + const isUvInstalling = useUvSetupStore((s) => s.isInstalling); + const checkUvStatus = useUvSetupStore((s) => s.checkStatus); + const installUv = useUvSetupStore((s) => s.install); + const finishUvInstall = useUvSetupStore((s) => s._finishInstall); + + const checkSkillsStatus = useCallback(async () => { + setSkillsChecking(true); + setSkillsError(null); + try { + const status = await invoke("check_skills_installed", { + projectPath: null, + }); + setSkillsStatus(status); + } catch (err) { + setSkillsStatus(null); + setSkillsError(String(err)); + } finally { + setSkillsChecking(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + + Promise.allSettled([ + checkClaudeStatus(), + checkUvStatus(), + checkSkillsStatus(), + ]).finally(() => { + if (!cancelled) { + setInitialCheckComplete(true); + } + }); + + return () => { + cancelled = true; + }; + }, [checkClaudeStatus, checkSkillsStatus, checkUvStatus]); + + useEffect(() => { + const unlisten = listen("uv-install-complete", (event) => { + finishUvInstall(event.payload); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [finishUvInstall]); + + const isClaudeInstalled = + claudeStatus === "ready" || claudeStatus === "not-authenticated"; + const isClaudeReady = claudeStatus === "ready"; + const isUvReady = uvStatus === "ready"; + const isSkillsReady = !!skillsStatus?.installed; + const claudeNeedsAttention = + isClaudeInstalling || (claudeStatus !== "checking" && !isClaudeReady); + const uvNeedsAttention = + isUvInstalling || (uvStatus !== "checking" && !isUvReady); + const skillsNeedsAttention = + !skillsChecking && (!isSkillsReady || !!skillsError); + const needsAttention = + claudeNeedsAttention || uvNeedsAttention || skillsNeedsAttention; + const isCheckingSetup = + claudeStatus === "checking" || uvStatus === "checking" || skillsChecking; + const setupComplete = + initialCheckComplete && !needsAttention && !isCheckingSetup; + const shouldShow = + initialCheckComplete && + !completedDismissed && + (needsAttention || + (isCheckingSetup && keepOpenDuringCheckRef.current) || + hasOpenedForSetup); + + useEffect(() => { + if (needsAttention) { + keepOpenDuringCheckRef.current = true; + setHasOpenedForSetup(true); + setCompletedDismissed(false); + return; + } + + if (!isCheckingSetup) { + keepOpenDuringCheckRef.current = false; + } + }, [isCheckingSetup, needsAttention]); + + const handleDone = () => { + if (!setupComplete) return; + keepOpenDuringCheckRef.current = false; + setHasOpenedForSetup(false); + setCompletedDismissed(true); + }; + + const openSkillsDialog = () => { + setSkillsDialogOpen(true); + if (!SkillsOnboardingComponent) { + import( + "@/components/scientific-skills/scientific-skills-onboarding" + ).then((mod) => + setSkillsOnboardingComponent(() => mod.ScientificSkillsOnboarding), + ); + } + }; + + const providerDetail = useMemo(() => { + if (!isClaudeInstalled) { + return "Install Claude Code before adding a provider"; + } + if (!isClaudeReady) { + return "Add an API key or sign in"; + } + const openAiProviderCount = Math.max( + openAiCredentials.length, + providerKind === "openai-compatible" ? 1 : 0, + ); + const includesClaudeProvider = + claudeProviderConfigured || providerKind === "claude-code"; + const count = openAiProviderCount + (includesClaudeProvider ? 1 : 0); + return `${count} provider${count === 1 ? "" : "s"} configured`; + }, [ + claudeProviderConfigured, + isClaudeInstalled, + isClaudeReady, + openAiCredentials.length, + providerKind, + ]); + + return ( + <> + undefined}> + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + className="w-[min(29rem,calc(100vw-2rem))] gap-0 overflow-hidden rounded-2xl border-border/70 p-0 shadow-xl sm:max-w-none" + > +
+ ClaudePrism + + + ClaudePrism + + + Set up the local tools and model provider required before + entering the workspace. + + +
+ +
+
+ { + shellOpen("https://git-scm.com/downloads/win"); + }, + } + : claudeStatus === "not-installed" || + claudeStatus === "error" + ? { + label: isClaudeInstalling ? "Installing" : "Install", + icon: isClaudeInstalling ? Loader2Icon : DownloadIcon, + loading: isClaudeInstalling, + onClick: installClaude, + } + : { + label: "Check", + icon: RefreshCwIcon, + onClick: checkClaudeStatus, + } + } + /> + + + + setProviderDialogOpen(true), + } + : { + label: "Locked", + icon: KeyRoundIcon, + disabled: true, + } + } + /> + + +
+
+ +
+ +
+
+
+ + + + + Add AI Provider + + Configure Anthropic or another model provider for this project. + + + setProviderDialogOpen(false)} + onSaved={() => { + setProviderDialogOpen(false); + checkClaudeStatus(); + }} + /> + + + + {skillsDialogOpen && SkillsOnboardingComponent && ( + { + setSkillsDialogOpen(false); + checkSkillsStatus(); + }} + /> + )} + + ); +} + +function SetupItem({ + state, + icon: Icon, + title, + detail, + action, +}: { + state: SetupItemState; + icon: typeof TerminalIcon; + title: string; + detail: string; + action?: { + label: string; + icon: typeof TerminalIcon; + onClick?: () => void; + loading?: boolean; + disabled?: boolean; + }; +}) { + const ActionIcon = action?.icon; + + return ( +
+
+ {state === "ready" ? ( + + ) : state === "loading" ? ( + + ) : state === "error" ? ( + + ) : ( + + )} +
+ +
+
+ {title} + {state === "blocked" && ( + + )} +
+

+ {detail} +

+
+ + {action && ( + + )} +
+ ); +} diff --git a/apps/desktop/src/components/model-capability-badges.tsx b/apps/desktop/src/components/model-capability-badges.tsx new file mode 100644 index 0000000..b3d4f19 --- /dev/null +++ b/apps/desktop/src/components/model-capability-badges.tsx @@ -0,0 +1,38 @@ +import { EyeIcon, MessageCircleIcon } from "lucide-react"; +import { getModelCapabilities } from "@/lib/model-capabilities"; +import { cn } from "@/lib/utils"; + +interface ModelCapabilityBadgesProps { + label?: string | null; + baseUrl?: string | null; + model?: string | null; + className?: string; +} + +export function ModelCapabilityBadges({ + label, + baseUrl, + model, + className, +}: ModelCapabilityBadgesProps) { + const capabilities = getModelCapabilities({ label, baseUrl, model }); + const badgeClass = + "inline-flex h-5 items-center justify-center rounded-md border border-neutral-300 bg-white text-neutral-600 dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-300"; + + return ( + + {capabilities.chat && ( + + + + )} + {capabilities.vision && ( + + + + )} + + ); +} diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index ff9a876..6e9ad6e 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -1,12 +1,20 @@ -import { useCallback, useEffect, useState } from "react"; +import { + type ComponentType, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; +import { readFile, readTextFile, stat } from "@tauri-apps/plugin-fs"; +import { toast } from "sonner"; import { FolderOpenIcon, - FolderPlusIcon, - ClockIcon, XIcon, FileTextIcon, SparklesIcon, @@ -14,19 +22,32 @@ import { CircleIcon, DownloadIcon, Loader2Icon, - RefreshCwIcon, - ArrowUpCircleIcon, + KeyRoundIcon, + SearchIcon, + PanelLeftIcon, + PlusIcon, + SettingsIcon, + GithubIcon, + MonitorIcon, + MoonIcon, + SunIcon, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useTheme } from "next-themes"; import { useProjectStore } from "@/stores/project-store"; import { useDocumentStore } from "@/stores/document-store"; import { useClaudeSetupStore } from "@/stores/claude-setup-store"; import { useUvSetupStore } from "@/stores/uv-setup-store"; -import { useUpdater } from "@/hooks/use-updater"; +import { useSettingsStore } from "@/stores/settings-store"; +import { compileLatex } from "@/lib/latex-compiler"; +import { getMupdfClient } from "@/lib/mupdf/mupdf-client"; +import { exists, join } from "@/lib/tauri/fs"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, + DialogFooter, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; @@ -34,11 +55,55 @@ import { ProjectWizard, type CreationMode } from "./project-wizard"; import { ClaudeSetup } from "./claude-setup"; import { cn } from "@/lib/utils"; +interface DefaultProject { + path: string; + name: string; + last_modified: number; + has_main_tex: boolean; +} + +type ProjectPickerSection = "projects" | "settings"; +type SettingsDetailSection = "provider" | "environment"; + +type RecentProject = { + path: string; + name: string; + lastOpened: number; +}; + +type ProjectPreviewData = { + createdAt: number | null; +} & ( + | { kind: "pdf"; url: string } + | { kind: "tex"; fileName: string; lines: string[] } + | { kind: "empty" } +); + +type ProjectPreviewState = + | { status: "loading" } + | { status: "ready"; data: ProjectPreviewData } + | { status: "error" }; + +const projectPreviewCache = new Map(); +const projectPreviewRequests = new Map>(); +let projectPreviewCompileQueue: Promise = Promise.resolve(); + export function ProjectPicker() { const [showModeDialog, setShowModeDialog] = useState(false); const [wizardMode, setWizardMode] = useState(null); const [appVersion, setAppVersion] = useState(""); - const { status: updateStatus, checkForUpdate, installUpdate } = useUpdater(); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [activeSection, setActiveSection] = + useState("projects"); + const [settingsDetailSection, setSettingsDetailSection] = + useState("provider"); + const [searchQuery, setSearchQuery] = useState(""); + const [removeProjectTarget, setRemoveProjectTarget] = + useState(null); + const defaultProjectsDiscoveredRef = useRef(false); + const searchInputRef = useRef(null); + const { theme = "system", setTheme } = useTheme(); + const searchShortcutLabel = "⌘ K"; const recentProjects = useProjectStore((s) => s.recentProjects); const addRecentProject = useProjectStore((s) => s.addRecentProject); @@ -54,21 +119,86 @@ export function ProjectPicker() { getVersion().then(setAppVersion); }, [checkClaudeStatus]); + useEffect(() => { + const handleSearchShortcut = (event: KeyboardEvent) => { + if ( + event.key.toLowerCase() !== "k" || + event.altKey || + event.shiftKey || + (!event.metaKey && !event.ctrlKey) + ) { + return; + } + + event.preventDefault(); + setActiveSection("projects"); + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + }; + + window.addEventListener("keydown", handleSearchShortcut); + return () => window.removeEventListener("keydown", handleSearchShortcut); + }, []); + + useEffect(() => { + if (defaultProjectsDiscoveredRef.current || recentProjects.length > 0) { + return; + } + defaultProjectsDiscoveredRef.current = true; + + let cancelled = false; + + async function discoverDefaultProjects() { + try { + const projects = await invoke( + "list_default_projects", + ); + if (cancelled || projects.length === 0) return; + + for (const project of [...projects].reverse()) { + addRecentProject(project.path); + } + } catch (err) { + console.warn("Failed to discover default projects:", err); + } + } + + discoverDefaultProjects(); + + return () => { + cancelled = true; + }; + }, [addRecentProject, recentProjects.length]); + const handleOpenFolder = async () => { - const selected = await open({ - directory: true, - multiple: false, - title: "Open Project Folder", - }); - if (selected) { - addRecentProject(selected); - await openProject(selected); + try { + const selected = await open({ + directory: true, + multiple: false, + title: "Open Project Folder", + }); + if (typeof selected === "string" && selected) { + await openProject(selected); + addRecentProject(selected); + } + } catch (err) { + console.warn("Failed to open selected project folder:", err); + toast.error("Failed to open project folder", { + description: err instanceof Error ? err.message : String(err), + }); } }; const handleOpenRecent = async (path: string) => { - addRecentProject(path); - await openProject(path); + try { + await openProject(path); + addRecentProject(path); + } catch (err) { + removeRecentProject(path); + console.warn("Failed to open recent project:", { path, error: err }); + } }; const handleSelectMode = (mode: CreationMode) => { @@ -76,6 +206,16 @@ export function ProjectPicker() { setWizardMode(mode); }; + const normalizedSearch = searchQuery.trim().toLowerCase(); + const visibleProjects = useMemo(() => { + if (!normalizedSearch) return recentProjects; + return recentProjects.filter( + (project) => + project.name.toLowerCase().includes(normalizedSearch) || + project.path.toLowerCase().includes(normalizedSearch), + ); + }, [normalizedSearch, recentProjects]); + if (wizardMode) { return ( setWizardMode(null)} /> @@ -83,88 +223,243 @@ export function ProjectPicker() { } return ( -
-
-
- ClaudePrism -

ClaudePrism

- -

- AI-powered academic writing workspace -

+
+ + +
+
+
+

+ {activeSection === "settings" ? "Settings" : "All Projects"} +

- )} -
+ + {activeSection === "projects" && ( +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search" + className="h-9 w-full rounded-lg border border-input bg-background pr-16 pl-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" + /> + + {searchShortcutLabel} + +
+ + + +
+ )} + + +
+ {activeSection === "settings" ? ( +
+ + +
+ {settingsDetailSection === "provider" ? ( + + + + ) : ( + + + + )} +
+
+ ) : ( +
+ {visibleProjects.length === 0 ? ( +
+ +

+ {normalizedSearch ? "No matching projects" : "No projects"} +

+
+ + +
+
+ ) : ( +
+ {visibleProjects.map((project) => ( + handleOpenRecent(project.path)} + onRemove={() => setRemoveProjectTarget(project)} + /> + ))} +
+ )} +
+ )} +
+ {/* New Project mode selection dialog */} @@ -176,10 +471,10 @@ export function ProjectPicker() {
+ + { + if (!open) setRemoveProjectTarget(null); + }} + > + + + Remove Project + + Remove "{removeProjectTarget?.name ?? "this project"}" from All + Projects? The project files will stay on disk. + + + + + + + +
); } @@ -221,10 +552,433 @@ interface SkillsStatus { location: string; } -function EnvironmentStatus() { - const claudeVersion = useClaudeSetupStore((s) => s.version); - const claudeEmail = useClaudeSetupStore((s) => s.accountEmail); +function projectPreviewCacheKey(project: RecentProject) { + return `${project.path}:${project.lastOpened}`; +} +function enqueueProjectPreviewCompile(task: () => Promise): Promise { + const run = projectPreviewCompileQueue.then(task, task); + projectPreviewCompileQueue = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +async function firstExistingProjectFile( + projectPath: string, + candidates: string[][], +): Promise<{ absolutePath: string; relativePath: string } | null> { + for (const segments of candidates) { + const absolutePath = await join(projectPath, ...segments); + if (await exists(absolutePath)) { + return { + absolutePath, + relativePath: segments.join("/"), + }; + } + } + return null; +} + +async function firstExistingPath( + projectPath: string, + candidates: string[][], +): Promise { + return ( + (await firstExistingProjectFile(projectPath, candidates))?.absolutePath ?? + null + ); +} + +async function renderPdfThumbnailFromBytes(bytes: Uint8Array): Promise { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const client = getMupdfClient(); + let docId: number | null = null; + + try { + docId = await client.openDocument(buffer); + const pngBuffer = await client.renderThumbnail(docId, 0, 420); + const blob = new Blob([new Uint8Array(pngBuffer)], { type: "image/png" }); + return URL.createObjectURL(blob); + } finally { + if (docId !== null) { + await client.closeDocument(docId).catch(() => {}); + } + } +} + +async function renderPdfThumbnail(pdfPath: string): Promise { + return renderPdfThumbnailFromBytes(await readFile(pdfPath)); +} + +function texPreviewLines(content: string) { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, 12) + .map((line) => (line.length > 70 ? `${line.slice(0, 67)}...` : line)); +} + +function statDateToMs(value: unknown): number | null { + if (!value) return null; + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : null; + } + if (typeof value === "number") { + if (!Number.isFinite(value) || value <= 0) return null; + return value < 1_000_000_000_000 ? value * 1000 : value; + } + if (typeof value === "string") { + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; + } + return null; +} + +async function getProjectCreatedAt( + projectPath: string, +): Promise { + try { + const info = (await stat(projectPath)) as { + birthtime?: unknown; + ctime?: unknown; + mtime?: unknown; + }; + return ( + statDateToMs(info.birthtime) ?? + statDateToMs(info.ctime) ?? + statDateToMs(info.mtime) + ); + } catch { + return null; + } +} + +function formatProjectCreatedDate(createdAt: number | null) { + if (!createdAt) return ""; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(createdAt)); +} + +async function loadProjectPreview( + project: RecentProject, +): Promise { + const cacheKey = projectPreviewCacheKey(project); + const cached = projectPreviewCache.get(cacheKey); + if (cached) return cached; + + const pending = projectPreviewRequests.get(cacheKey); + if (pending) return pending; + + const promise = (async () => { + const createdAt = await getProjectCreatedAt(project.path); + const pdfPath = await firstExistingPath(project.path, [ + [".prism", "build", "main.pdf"], + [".prism", "build", "document.pdf"], + ["main.pdf"], + ["document.pdf"], + ]); + + if (pdfPath) { + const data: ProjectPreviewData = { + kind: "pdf", + url: await renderPdfThumbnail(pdfPath), + createdAt, + }; + projectPreviewCache.set(cacheKey, data); + return data; + } + + const texFile = await firstExistingProjectFile(project.path, [ + ["main.tex"], + ["document.tex"], + ]); + + if (texFile) { + try { + const useTexlive = + useSettingsStore.getState().compilerBackend === "texlive"; + const pdfBytes = await enqueueProjectPreviewCompile(() => + compileLatex(project.path, texFile.relativePath, useTexlive), + ); + const data: ProjectPreviewData = { + kind: "pdf", + url: await renderPdfThumbnailFromBytes(pdfBytes), + createdAt, + }; + projectPreviewCache.set(cacheKey, data); + return data; + } catch (err) { + console.warn("Failed to compile project preview:", { + path: project.path, + target: texFile.relativePath, + error: err, + }); + } + + const fileName = texFile.absolutePath.split(/[\\/]/).pop() ?? "main.tex"; + const data: ProjectPreviewData = { + kind: "tex", + fileName, + lines: texPreviewLines(await readTextFile(texFile.absolutePath)), + createdAt, + }; + projectPreviewCache.set(cacheKey, data); + return data; + } + + const data: ProjectPreviewData = { kind: "empty", createdAt }; + projectPreviewCache.set(cacheKey, data); + return data; + })(); + + projectPreviewRequests.set(cacheKey, promise); + try { + return await promise; + } finally { + projectPreviewRequests.delete(cacheKey); + } +} + +function ProjectPreviewCard({ + project, + onOpen, + onRemove, +}: { + project: RecentProject; + onOpen: () => void; + onRemove: () => void; +}) { + const [preview, setPreview] = useState(() => { + const cached = projectPreviewCache.get(projectPreviewCacheKey(project)); + return cached ? { status: "ready", data: cached } : { status: "loading" }; + }); + const createdDateLabel = + preview.status === "ready" + ? formatProjectCreatedDate(preview.data.createdAt) + : ""; + + useEffect(() => { + let cancelled = false; + const cacheKey = projectPreviewCacheKey(project); + const cached = projectPreviewCache.get(cacheKey); + if (cached) { + setPreview({ status: "ready", data: cached }); + return; + } + + setPreview({ status: "loading" }); + loadProjectPreview(project) + .then((data) => { + if (!cancelled) setPreview({ status: "ready", data }); + }) + .catch((err) => { + console.warn("Failed to load project preview:", { + path: project.path, + error: err, + }); + if (!cancelled) setPreview({ status: "error" }); + }); + + return () => { + cancelled = true; + }; + }, [project]); + + return ( +
+
+ + +
+ +
+ {createdDateLabel} +
+
+ ); +} + +function ProjectPreviewSurface({ + preview, + projectName, +}: { + preview: ProjectPreviewState; + projectName: string; +}) { + if (preview.status === "loading") { + return ( +
+ +
+ ); + } + + if (preview.status === "ready" && preview.data.kind === "pdf") { + return ( + {`${projectName} + ); + } + + if (preview.status === "ready" && preview.data.kind === "tex") { + return ( +
+
+ + {preview.data.fileName} + +
+
+ {preview.data.lines.map((line, index) => ( +
+ {line} +
+ ))} +
+
+ ); + } + + return ( +
+ + No preview +
+ ); +} + +function ProjectNavButton({ + active, + collapsed, + icon: Icon, + onClick, + children, +}: { + active: boolean; + collapsed: boolean; + icon: LucideIcon; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function SettingsDetailButton({ + active, + icon: Icon, + label, + meta, + onClick, +}: { + active: boolean; + icon: LucideIcon; + label: string; + meta: string; + onClick: () => void; +}) { + return ( + + ); +} + +function SettingsPanel({ + title, + icon: Icon, + contentClassName, + children, +}: { + title: string; + icon: LucideIcon; + contentClassName?: string; + children: ReactNode; +}) { + return ( +
+
+
+ +
+
+

{title}

+
+
+
{children}
+
+ ); +} + +function EnvironmentStatus({ appVersion }: { appVersion: string }) { const uvStatus = useUvSetupStore((s) => s.status); const uvVersion = useUvSetupStore((s) => s.version); const uvInstalling = useUvSetupStore((s) => s.isInstalling); @@ -263,10 +1017,9 @@ function EnvironmentStatus() { }, [_finishUvInstall]); // Lazy load skills onboarding - const [OnboardingComponent, setOnboardingComponent] = - useState void; - }> | null>(null); + const [OnboardingComponent, setOnboardingComponent] = useState void; + }> | null>(null); useEffect(() => { if (showSkillsOnboarding && !OnboardingComponent) { @@ -280,14 +1033,7 @@ function EnvironmentStatus() { return ( <> -
- {/* Claude Code — always ready here */} - - +
{/* Python (uv) */} setShowSkillsOnboarding(true), + icon: skillsStatus?.installed ? "settings" : "download", } - : undefined } /> + +
{showSkillsOnboarding && OnboardingComponent && ( @@ -353,36 +1106,56 @@ function StatusRow({ ok: boolean; label: string; detail: string; - action?: { label: string; onClick?: () => void; loading?: boolean }; + action?: { + label: string; + onClick?: () => void; + loading?: boolean; + icon?: "download" | "key" | "settings"; + }; }) { return ( -
- {ok ? ( - - ) : ( - - )} - +
- {label} - - - {detail} - + {ok ? ( + + ) : ( + + )} +
+
+ + {label} + + + {detail} + +
{action && (
); } - -// ─── Version Badge with Update Status ─── - -function VersionBadge({ - version, - updateStatus, - onCheck, - onInstall, -}: { - version: string; - updateStatus: import("@/hooks/use-updater").UpdateStatus; - onCheck: () => void; - onInstall: () => void; -}) { - if (!version) return null; - - switch (updateStatus.state) { - case "available": - return ( - - ); - - case "downloading": - return ( -
- - Downloading... {updateStatus.percent}% -
- ); - - case "installing": - return ( -
- - Installing... -
- ); - - case "ready": - return ( -
- - Update complete — restarting... -
- ); - - case "checking": - return ( -
- v{version} — checking - for updates... -
- ); - - case "error": - return ( -
- v{version} - · - -
- ); - - case "up-to-date": - return ( -
- v{version} - · - -
- ); - - default: - return ( -
- v{version} - · - -
- ); - } -} diff --git a/apps/desktop/src/components/project-wizard.tsx b/apps/desktop/src/components/project-wizard.tsx index 1960955..ba14bd0 100644 --- a/apps/desktop/src/components/project-wizard.tsx +++ b/apps/desktop/src/components/project-wizard.tsx @@ -30,40 +30,14 @@ import { } from "@/lib/template-registry"; import { TemplateGallery } from "@/components/template-gallery"; import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md"; +import { + buildReferenceFilesSection, + importReferenceFiles, +} from "@/lib/project-attachments"; +import { getProjectNameError, normalizeProjectName } from "@/lib/project-name"; // ─── Helpers ─── -function randomProjectName(): string { - const adjectives = [ - "swift", - "bright", - "calm", - "bold", - "keen", - "warm", - "pure", - "vast", - "deep", - "fair", - ]; - const nouns = [ - "paper", - "draft", - "thesis", - "note", - "study", - "essay", - "report", - "brief", - "folio", - "opus", - ]; - const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; - const noun = nouns[Math.floor(Math.random() * nouns.length)]; - const id = Math.random().toString(36).slice(2, 6); - return `${adj}-${noun}-${id}`; -} - // ─── Wizard Component ─── export type CreationMode = "template" | "scratch"; @@ -107,12 +81,14 @@ function ScratchForm({ onBack }: { onBack: () => void }) { const [purpose, setPurpose] = useState(""); const [attachments, setAttachments] = useState([]); const [projectFolder, setProjectFolder] = useState(null); - const [projectName, setProjectName] = useState(randomProjectName); + const [projectName, setProjectName] = useState(""); + const [projectNameError, setProjectNameError] = useState(""); const [isCreating, setIsCreating] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [refFilesOpen, setRefFilesOpen] = useState(false); const [locationOpen, setLocationOpen] = useState(false); + const projectNameRef = useRef(null); const textareaRef = useRef(null); const addRecentProject = useProjectStore((s) => s.addRecentProject); @@ -123,7 +99,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) { const template = getTemplateById("blank")!; useEffect(() => { - const timer = setTimeout(() => textareaRef.current?.focus(), 100); + const timer = setTimeout(() => projectNameRef.current?.focus(), 100); return () => clearTimeout(timer); }, []); @@ -160,26 +136,6 @@ function ScratchForm({ onBack }: { onBack: () => void }) { const selected = await open({ multiple: true, title: "Add Reference Files", - filters: [ - { - name: "Documents & Images", - extensions: [ - "pdf", - "tex", - "bib", - "txt", - "md", - "png", - "jpg", - "jpeg", - "gif", - "svg", - "csv", - "tsv", - "json", - ], - }, - ], }); if (selected) { const paths = Array.isArray(selected) ? selected : [selected]; @@ -232,11 +188,20 @@ function ScratchForm({ onBack }: { onBack: () => void }) { }, []); const handleCreate = async () => { - if (!template || !projectFolder || !projectName.trim()) return; + const name = normalizeProjectName(projectName); + const nameError = getProjectNameError(projectName); + if (!template || !projectFolder || nameError) { + setProjectNameError(nameError ?? ""); + return; + } setIsCreating(true); try { - const projectPath = await join(projectFolder, projectName.trim()); + const projectPath = await join(projectFolder, name); + if (await exists(projectPath)) { + setProjectNameError("A folder with this name already exists here"); + return; + } await mkdir(projectPath, { recursive: true }); // Create CLAUDE.md for Claude Code context @@ -260,19 +225,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) { } } - if (attachments.length > 0) { - const attachmentsDir = await join(projectPath, "attachments"); - await mkdir(attachmentsDir, { recursive: true }); - } + const referenceFiles = + attachments.length > 0 + ? await importReferenceFiles(projectPath, attachments) + : []; if (purpose.trim()) { - const attachmentNames = attachments - .map((p) => p.split(/[/\\]/).pop()) - .filter(Boolean); - const attachmentSection = - attachmentNames.length > 0 - ? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n` - : ""; + const attachmentSection = buildReferenceFilesSection(referenceFiles); const prompt = [ `## New ${template.name} Project`, @@ -298,12 +257,6 @@ function ScratchForm({ onBack }: { onBack: () => void }) { setLastProjectFolder(projectFolder); addRecentProject(projectPath); await openProject(projectPath); - - if (attachments.length > 0) { - await useDocumentStore - .getState() - .importFiles(attachments, "attachments"); - } } catch (err) { console.error("Failed to create project:", err); toast.error("Failed to create project", { @@ -314,7 +267,9 @@ function ScratchForm({ onBack }: { onBack: () => void }) { } }; - const canCreate = template && projectFolder && projectName.trim(); + const canCreate = Boolean( + template && projectFolder && !getProjectNameError(projectName), + ); return (
@@ -334,6 +289,32 @@ function ScratchForm({ onBack }: { onBack: () => void }) { {/* Form */}
+ {/* Project name */} +
+
+ Project name +

+ This becomes the folder name on disk. +

+
+ { + setProjectName(e.target.value); + setProjectNameError(""); + }} + onBlur={() => + setProjectNameError(getProjectNameError(projectName) ?? "") + } + className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50" + /> + {projectNameError && ( +

{projectNameError}

+ )} +
+ {/* Purpose */}
@@ -454,7 +435,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) { {!locationOpen && projectFolder && projectName.trim() && ( .../{projectFolder.split(/[/\\]/).pop()}/ - {projectName.trim()} + {normalizeProjectName(projectName)} )} void }) { {locationOpen && (
-
- setProjectName(e.target.value)} - className="flex-1 rounded-lg border-border/60 bg-background/50" - /> +
+

+ {projectFolder + ? `${projectFolder}/${normalizeProjectName(projectName) || "..."}` + : "Choose a location"} +

- {projectFolder && ( -

- {projectFolder}/{projectName.trim() || "..."} -

- )}
)}
diff --git a/apps/desktop/src/components/scientific-skills/install-progress.tsx b/apps/desktop/src/components/scientific-skills/install-progress.tsx index 946a5cf..0b5e49c 100644 --- a/apps/desktop/src/components/scientific-skills/install-progress.tsx +++ b/apps/desktop/src/components/scientific-skills/install-progress.tsx @@ -1,13 +1,13 @@ -import { useEffect, useRef, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { useMemo } from "react"; import { Progress } from "@/components/ui/progress"; -import { ScrollArea } from "@/components/ui/scroll-area"; const PHASE_MAP: Record = { + "Preparing installer": 0, "Checking directory permissions...": 5, "Directory permissions OK": 10, "Git available": 15, "cloning repository": 20, + "Downloading skills": 20, "downloading tarball": 20, "Download complete": 60, "Copying skills": 70, @@ -16,6 +16,18 @@ const PHASE_MAP: Record = { }; function pctFromLog(log: string): number | null { + const downloadMatch = log.match(/^Download progress\s+(\d+)%/i); + if (downloadMatch?.[1]) { + const downloadPct = Math.max(0, Math.min(100, Number(downloadMatch[1]))); + return Math.round(20 + downloadPct * 0.4); + } + + const downloadedMatch = log.match(/^Downloaded\s+(\d+)\s+MiB/i); + if (downloadedMatch?.[1]) { + const mib = Math.max(0, Number(downloadedMatch[1])); + return Math.min(55, 20 + mib); + } + for (const [key, pct] of Object.entries(PHASE_MAP)) { if (log.toLowerCase().includes(key.toLowerCase())) return pct; } @@ -26,37 +38,21 @@ interface InstallProgressProps { isInstalling: boolean; isComplete: boolean; error: string | null; + logs: string[]; } export function InstallProgress({ - isInstalling, isComplete, error, + logs, }: InstallProgressProps) { - const [logs, setLogs] = useState([]); - const [pct, setPct] = useState(0); - const scrollRef = useRef(null); - - useEffect(() => { - if (!isInstalling) return; - const unlisten = listen("skills-install-log", (event) => { - setLogs((prev) => [...prev, event.payload]); - const p = pctFromLog(event.payload); - if (p !== null) setPct(p); - }); - return () => { - unlisten.then((fn) => fn()); - }; - }, [isInstalling]); - - useEffect(() => { - if (isComplete) setPct(100); - }, [isComplete]); - - // Auto-scroll to bottom - useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [logs]); + const pct = useMemo(() => { + if (isComplete) return 100; + return logs.reduce((current, line) => { + const next = pctFromLog(line); + return next === null ? current : Math.max(current, next); + }, 0); + }, [isComplete, logs]); const label = isComplete ? "Done" @@ -77,18 +73,6 @@ export function InstallProgress({ {pct}%

- {logs.length > 0 && ( - -
- {logs.map((line, i) => ( -
{line}
- ))} -
-
- )}
); } diff --git a/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx b/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx index f7ee134..efb12e3 100644 --- a/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx +++ b/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx @@ -1,5 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; +import { toast } from "sonner"; import { FlaskConicalIcon, DownloadIcon, @@ -10,6 +13,8 @@ import { Trash2Icon, Loader2Icon, ChevronLeftIcon, + FolderPlusIcon, + XIcon, } from "lucide-react"; import { Dialog, @@ -45,6 +50,14 @@ interface SkillsStatus { location: string; } +interface SkillInfo { + id: string; + name: string; + domain: string; + description: string; + folder: string; +} + interface ScientificSkillsOnboardingProps { onClose: () => void; } @@ -56,22 +69,60 @@ export function ScientificSkillsOnboarding({ const [selectedId, setSelectedId] = useState(null); const [isInstalling, setIsInstalling] = useState(false); const [isComplete, setIsComplete] = useState(false); + const [installLogs, setInstallLogs] = useState([]); const [installResult, setInstallResult] = useState( null, ); const [error, setError] = useState(null); const [status, setStatus] = useState(null); + const [installedSkills, setInstalledSkills] = useState([]); const [isUninstalling, setIsUninstalling] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletingSkillFolder, setDeletingSkillFolder] = useState( + null, + ); + const mountedRef = useRef(true); + const installBackendLogSeenRef = useRef(false); - // Check global install status + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + const unlisten = listen("skills-install-log", (event) => { + installBackendLogSeenRef.current = true; + setInstallLogs((previous) => { + const last = previous[previous.length - 1]; + if (last === event.payload) return previous; + return [...previous, event.payload]; + }); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, []); + + // Skills are global Claude Code assets under ~/.claude/skills. const checkStatus = useCallback(async () => { try { - const gs = await invoke("check_skills_installed", { - projectPath: null, - }); + const [gs, skills] = await Promise.all([ + invoke("check_skills_installed", { + projectPath: null, + }), + invoke("list_installed_skills", { + projectPath: null, + }), + ]); setStatus(gs); + setInstalledSkills(skills); } catch { setStatus(null); + setInstalledSkills([]); } }, []); @@ -88,24 +139,88 @@ export function ScientificSkillsOnboarding({ .catch(console.error); }, []); - const totalSkills = categories.reduce((sum, c) => sum + c.skill_count, 0); - const selected = categories.find((c) => c.id === selectedId) ?? null; + const knownSkillFolders = new Set( + categories.flatMap((category) => + category.skills.map((skill) => skill.folder), + ), + ); + const importedSkills = installedSkills.filter( + (skill) => !knownSkillFolders.has(skill.folder), + ); + const installedSkillFolders = new Set( + installedSkills.map((skill) => skill.folder), + ); + const displayCategories: SkillCategoryData[] = + importedSkills.length > 0 + ? [ + ...categories, + { + id: "imported", + name: "Imported Skills", + icon: "settings", + skill_count: importedSkills.length, + skills: importedSkills.map((skill) => ({ + name: skill.name, + folder: skill.folder, + })), + }, + ] + : categories; + + const totalSkills = displayCategories.reduce( + (sum, c) => sum + c.skill_count, + 0, + ); + const selected = + displayCategories.find((c) => c.id === selectedId) ?? + displayCategories[0] ?? + null; const isInstalled = status?.installed ?? false; const handleInstall = useCallback(async () => { + installBackendLogSeenRef.current = false; + let noBackendLogTimer: number | undefined; + setInstallLogs(["Preparing installer..."]); setIsInstalling(true); + setIsComplete(false); + setInstallResult(null); setError(null); try { + noBackendLogTimer = window.setTimeout(() => { + if (installBackendLogSeenRef.current || !mountedRef.current) return; + setInstallLogs((previous) => { + const hasBackendLog = previous.some( + (line) => !line.startsWith("Preparing installer"), + ); + if (hasBackendLog) return previous; + return [...previous, "Waiting for the installer command to start..."]; + }); + }, 2500); + + await new Promise((resolve) => window.setTimeout(resolve, 150)); + if (!mountedRef.current) return; + const result = await invoke( "install_scientific_skills_global", + {}, ); + if (noBackendLogTimer !== undefined) { + window.clearTimeout(noBackendLogTimer); + } + if (!mountedRef.current) return; setInstallResult(result); setIsComplete(true); localStorage.setItem(STORAGE_KEY, "true"); await checkStatus(); } catch (e) { - setError(String(e)); + if (noBackendLogTimer !== undefined) { + window.clearTimeout(noBackendLogTimer); + } + if (!mountedRef.current) return; + const message = String(e); + setInstallLogs((previous) => [...previous, message]); + setError(message); setIsInstalling(false); } }, [checkStatus]); @@ -113,7 +228,9 @@ export function ScientificSkillsOnboarding({ const handleUninstall = useCallback(async () => { setIsUninstalling(true); try { - await invoke("uninstall_scientific_skills", { projectPath: null }); + await invoke("uninstall_scientific_skills", { + projectPath: null, + }); await checkStatus(); const gsAfter = await invoke("check_skills_installed", { projectPath: null, @@ -121,31 +238,110 @@ export function ScientificSkillsOnboarding({ if (!gsAfter.installed) { localStorage.removeItem(STORAGE_KEY); } + toast.success("All skills uninstalled"); } catch (e) { console.error("Failed to uninstall:", e); + toast.error("Failed to uninstall skills", { + description: String(e), + }); } finally { setIsUninstalling(false); } }, [checkStatus]); + const handleImportSkill = useCallback(async () => { + setIsImporting(true); + try { + const selectedFolder = await open({ + directory: true, + multiple: false, + title: "Import Claude Skill Folder", + }); + + if (typeof selectedFolder !== "string") return; + + const skills = await invoke("import_skill_from_folder", { + sourcePath: selectedFolder, + }); + + localStorage.setItem(STORAGE_KEY, "true"); + await checkStatus(); + setSelectedId("imported"); + toast.success("Skill imported", { + description: + skills.length === 1 + ? skills[0]?.name + : `${skills.length} skills imported`, + }); + } catch (e) { + toast.error("Failed to import skill", { + description: String(e), + }); + } finally { + setIsImporting(false); + } + }, [checkStatus]); + + const handleConfirmDeleteSkill = useCallback(async () => { + if (!deleteTarget) return; + setDeletingSkillFolder(deleteTarget.folder); + try { + await invoke("delete_installed_skill", { + skillFolder: deleteTarget.folder, + }); + toast.success("Skill deleted", { + description: deleteTarget.name, + }); + setDeleteTarget(null); + await checkStatus(); + } catch (e) { + toast.error("Failed to delete skill", { + description: String(e), + }); + } finally { + setDeletingSkillFolder(null); + } + }, [checkStatus, deleteTarget]); + // ─── Installing / Complete state ─── - if (isInstalling || isComplete) { + if (isInstalling || isComplete || error) { return ( { - if (!open && (isComplete || error)) onClose(); + if (!open) onClose(); }} > - + + {isComplete ? ( + ) : error ? ( + ) : ( )} - {isComplete ? "Installation Complete" : "Installing Skills"} + {isComplete + ? "Installation Complete" + : error + ? "Installation Failed" + : "Installing Skills"} {isComplete && ( @@ -159,6 +355,7 @@ export function ScientificSkillsOnboarding({ isInstalling={isInstalling} isComplete={isComplete} error={error} + logs={installLogs} /> {error && ( @@ -175,10 +372,7 @@ export function ScientificSkillsOnboarding({ + + + ) : ( + + )} +
-
- {isInstalled ? ( - <> - - - {status?.skill_count} installed - - - - - ) : ( - +
+
+ + + {/* Body — sidebar + detail */} +
+ {/* Category sidebar */} + + + {/* Detail panel */} +
+ {selected ? ( + +
+ +
+
+ ) : ( +
+ Select a category +
)}
- - {/* Body — sidebar + detail */} -
- {/* Category sidebar */} - - - {/* Detail panel */} -
- {selected ? ( - -
- -
-
- ) : ( -
- Select a category -
- )} + {/* Footer */} +
+

+ {status?.location ?? "~/.claude/skills/"} +

+
-
+ + - {/* Footer */} -
-

- {isInstalled ? status?.location : "~/.claude/skills/"} -

- -
- - + { + if (!open && !deletingSkillFolder) setDeleteTarget(null); + }} + > + + + Delete Skill + + Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills. + This cannot be undone. + + +
+ {deleteTarget?.folder} +
+
+ + +
+
+
+ + { + if (!open && !isUninstalling) setConfirmUninstallAllOpen(false); + }} + > + + + Uninstall All Skills + + This will delete every skill under ~/.claude/skills, including + imported local skills. This cannot be undone. + + +
+ {status?.skill_count ?? 0} installed skills will be removed. +
+
+ + +
+
+
+ ); } @@ -345,9 +648,15 @@ export function ScientificSkillsOnboarding({ function CategoryDetail({ category, isInstalled, + installedSkillFolders, + deletingSkillFolder, + onDeleteSkill, }: { category: SkillCategoryData; isInstalled: boolean; + installedSkillFolders: Set; + deletingSkillFolder: string | null; + onDeleteSkill: (skill: SkillEntryData) => void; }) { const Icon = ICON_MAP[category.icon] || FlaskConicalIcon; const [selectedSkill, setSelectedSkill] = useState( @@ -417,6 +726,22 @@ function CategoryDetail({ {selectedSkill.folder}

+ {installedSkillFolders.has(selectedSkill.folder) && ( + + )}
@@ -473,16 +798,44 @@ function CategoryDetail({ Skills
- {category.skills.map((skill) => ( - - ))} + {category.skills.map((skill) => { + const canDelete = installedSkillFolders.has(skill.folder); + const isDeleting = deletingSkillFolder === skill.folder; + return ( +
+ + {canDelete && ( + + )} +
+ ); + })}
diff --git a/apps/desktop/src/components/template-gallery/template-preview.tsx b/apps/desktop/src/components/template-gallery/template-preview.tsx index 0eff714..77ba36e 100644 --- a/apps/desktop/src/components/template-gallery/template-preview.tsx +++ b/apps/desktop/src/components/template-gallery/template-preview.tsx @@ -43,42 +43,16 @@ import { getMupdfClient } from "@/lib/mupdf/mupdf-client"; import { exists, join } from "@/lib/tauri/fs"; import type { PageSize } from "@/lib/mupdf/types"; import { createLogger } from "@/lib/debug/logger"; +import { + buildReferenceFilesSection, + importReferenceFiles, +} from "@/lib/project-attachments"; +import { getProjectNameError, normalizeProjectName } from "@/lib/project-name"; const log = createLogger("template-preview"); // ─── Helpers ─── -function randomProjectName(): string { - const adjectives = [ - "swift", - "bright", - "calm", - "bold", - "keen", - "warm", - "pure", - "vast", - "deep", - "fair", - ]; - const nouns = [ - "paper", - "draft", - "thesis", - "note", - "study", - "essay", - "report", - "brief", - "folio", - "opus", - ]; - const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; - const noun = nouns[Math.floor(Math.random() * nouns.length)]; - const id = Math.random().toString(36).slice(2, 6); - return `${adj}-${noun}-${id}`; -} - // ─── Component ─── type ModalStep = "preview" | "details"; @@ -108,11 +82,13 @@ export function TemplatePreview() { const [purpose, setPurpose] = useState(""); const [attachments, setAttachments] = useState([]); const [projectFolder, setProjectFolder] = useState(null); - const [projectName, setProjectName] = useState(randomProjectName); + const [projectName, setProjectName] = useState(""); + const [projectNameError, setProjectNameError] = useState(""); const [isCreating, setIsCreating] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [refFilesOpen, setRefFilesOpen] = useState(false); const [locationOpen, setLocationOpen] = useState(false); + const projectNameRef = useRef(null); const textareaRef = useRef(null); // ── Store access ── @@ -148,7 +124,8 @@ export function TemplatePreview() { setModalStep("preview"); setPurpose(""); setAttachments([]); - setProjectName(randomProjectName()); + setProjectName(""); + setProjectNameError(""); setRefFilesOpen(false); setLocationOpen(false); } @@ -175,7 +152,7 @@ export function TemplatePreview() { // Auto-focus textarea in details step useEffect(() => { if (modalStep === "details") { - const timer = setTimeout(() => textareaRef.current?.focus(), 150); + const timer = setTimeout(() => projectNameRef.current?.focus(), 150); return () => clearTimeout(timer); } }, [modalStep]); @@ -354,26 +331,6 @@ export function TemplatePreview() { const selected = await open({ multiple: true, title: "Add Reference Files", - filters: [ - { - name: "Documents & Images", - extensions: [ - "pdf", - "tex", - "bib", - "txt", - "md", - "png", - "jpg", - "jpeg", - "gif", - "svg", - "csv", - "tsv", - "json", - ], - }, - ], }); if (selected) { const paths = Array.isArray(selected) ? selected : [selected]; @@ -402,11 +359,20 @@ export function TemplatePreview() { // ── Create project ── const handleCreate = async () => { - if (!template || !projectFolder || !projectName.trim()) return; + const name = normalizeProjectName(projectName); + const nameError = getProjectNameError(projectName); + if (!template || !projectFolder || nameError) { + setProjectNameError(nameError ?? ""); + return; + } setIsCreating(true); try { - const projectPath = await join(projectFolder, projectName.trim()); + const projectPath = await join(projectFolder, name); + if (await exists(projectPath)) { + setProjectNameError("A folder with this name already exists here"); + return; + } await mkdir(projectPath, { recursive: true }); const mainTexPath = await join(projectPath, template.mainFileName); @@ -423,19 +389,13 @@ export function TemplatePreview() { } } - if (attachments.length > 0) { - const attachmentsDir = await join(projectPath, "attachments"); - await mkdir(attachmentsDir, { recursive: true }); - } + const referenceFiles = + attachments.length > 0 + ? await importReferenceFiles(projectPath, attachments) + : []; if (purpose.trim()) { - const attachmentNames = attachments - .map((p) => p.split(/[/\\]/).pop()) - .filter(Boolean); - const attachmentSection = - attachmentNames.length > 0 - ? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n` - : ""; + const attachmentSection = buildReferenceFilesSection(referenceFiles); const prompt = [ `## New ${template.name} Project`, @@ -462,12 +422,6 @@ export function TemplatePreview() { addRecentProject(projectPath); await openProject(projectPath); - if (attachments.length > 0) { - await useDocumentStore - .getState() - .importFiles(attachments, "attachments"); - } - // Close modal on success closePreview(); } catch (err) { @@ -480,7 +434,9 @@ export function TemplatePreview() { } }; - const canCreate = template && projectFolder && projectName.trim(); + const canCreate = Boolean( + template && projectFolder && !getProjectNameError(projectName), + ); if (!template) return null; @@ -603,6 +559,34 @@ export function TemplatePreview() { {/* Form content */}
+
+
+ Project name +

+ This becomes the folder name on disk. +

+
+ { + setProjectName(e.target.value); + setProjectNameError(""); + }} + onBlur={() => + setProjectNameError( + getProjectNameError(projectName) ?? "", + ) + } + className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50" + /> + {projectNameError && ( +

+ {projectNameError} +

+ )} +
{/* Purpose — hero element */}
@@ -724,7 +708,7 @@ export function TemplatePreview() { {!locationOpen && projectFolder && projectName.trim() && ( .../{projectFolder.split(/[/\\]/).pop()}/ - {projectName.trim()} + {normalizeProjectName(projectName)} )} {locationOpen && (
-
- setProjectName(e.target.value)} - className="flex-1 rounded-lg border-border/60 bg-background/50" - /> +
+

+ {projectFolder + ? `${projectFolder}/${normalizeProjectName(projectName) || "..."}` + : "Choose a location"} +

- {projectFolder && ( -

- {projectFolder}/{projectName.trim() || "..."} -

- )}
)}
diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx index 4f2cdfd..7142e04 100644 --- a/apps/desktop/src/components/ui/context-menu.tsx +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -13,7 +13,11 @@ function ContextMenuTrigger({ ...props }: React.ComponentProps) { return ( - + ); } @@ -24,6 +28,7 @@ function ContextMenuContent({ return ( !o && onClose()}> - + - - - Python Environment (uv) + + + Python Environment (uv) Manage the Python virtual environment for this project. -
+
{/* uv status */} -
+
-
+
{status === "checking" ? "Checking uv..." @@ -102,7 +102,9 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
)} {error && ( -
{error}
+
+ {error} +
)}
{status === "not-installed" && !isInstalling && ( @@ -121,10 +123,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) { {/* venv status — only show when uv is ready */} {status === "ready" && ( -
+
-
+
{venvReady ? "Virtual Environment Active" @@ -140,7 +142,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{venvPath && (
{venvPath} @@ -165,9 +167,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) { {/* Info text */} {status === "ready" && venvReady && ( -

- Claude Code will automatically use this environment when running - Python code. Use{" "} +

+ Claude Code and ClaudePrism terminal tools use this environment + when running Python code. OpenAI-compatible providers use it + through PowerShell/Bash tool calls. Use{" "} uv pip install to add packages.

@@ -187,28 +190,28 @@ function StatusIcon({ }) { if (isInstalling || status === "checking") { return ( -
+
); } if (status === "ready") { return ( -
+
); } if (status === "error") { return ( -
+
); } // not-installed return ( -
+
); diff --git a/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx b/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx index e6b8ec4..ab1982b 100644 --- a/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx +++ b/apps/desktop/src/components/workspace/editor/editor-toolbar.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui/button"; +import vscodeIcon from "@/assets/vscode.svg"; import { DropdownMenu, DropdownMenuContent, @@ -51,6 +52,28 @@ const ZOOM_OPTIONS = [ { value: "4", label: "400%" }, ]; +function OpenEditorIcon({ editor }: { editor: EditorInfo }) { + if (editor.id === "vscode") { + return ( + + ); + } + + return ; +} + +function getOpenEditorButtonClassName(editor: EditorInfo) { + return editor.id === "vscode" + ? "h-7 w-7 border border-border/70 bg-muted/30 p-1 hover:bg-muted/50" + : undefined; +} + interface EditorToolbarProps { editorView: RefObject; fileType?: "tex" | "image"; @@ -136,10 +159,13 @@ export function EditorToolbar({ if (fileType === "image") { return ( -
-
- - +
+
+ + {fileName}
@@ -196,8 +222,9 @@ export function EditorToolbar({ openInEditor(editors[0].id)} + className={getOpenEditorButtonClassName(editors[0])} > - + )} {editors.length > 1 && ( @@ -230,12 +257,17 @@ export function EditorToolbar({ } return ( -
- - - {fileName} - -
+
+
+ + + {fileName} + +
+
insertText("\\textbf{", "}")} @@ -308,8 +340,9 @@ export function EditorToolbar({ openInEditor(editors[0].id)} + className={getOpenEditorButtonClassName(editors[0])} > - + )} {editors.length > 1 && ( diff --git a/apps/desktop/src/components/workspace/editor/latex-editor.tsx b/apps/desktop/src/components/workspace/editor/latex-editor.tsx index c4ea71d..aa01848 100644 --- a/apps/desktop/src/components/workspace/editor/latex-editor.tsx +++ b/apps/desktop/src/components/workspace/editor/latex-editor.tsx @@ -48,7 +48,10 @@ import { useProposedChangesStore, type ProposedChange, } from "@/stores/proposed-changes-store"; -import { useClaudeChatStore } from "@/stores/claude-chat-store"; +import { + useClaudeChatStore, + type PromptContextOverride, +} from "@/stores/claude-chat-store"; import { useHistoryStore, type FileDiff } from "@/stores/history-store"; import { compileLatex, @@ -354,18 +357,24 @@ export function LatexEditor() { if (searchQuery) findNext(view); }, [searchQuery]); - const handleFindNext = () => { + const handleFindNext = (options?: { focusEditor?: boolean }) => { const view = viewRef.current; if (view) { findNext(view); - view.focus(); + if (matchCount > 0) { + setCurrentMatch((current) => (current % matchCount) + 1); + } + if (options?.focusEditor !== false) view.focus(); } }; - const handleFindPrevious = () => { + const handleFindPrevious = (options?: { focusEditor?: boolean }) => { const view = viewRef.current; if (view) { findPrevious(view); - view.focus(); + if (matchCount > 0) { + setCurrentMatch((current) => (current <= 1 ? matchCount : current - 1)); + } + if (options?.focusEditor !== false) view.focus(); } }; @@ -986,14 +995,44 @@ export function LatexEditor() { return { top: relTop, left: relLeft }; }, [selectionCoords]); - const handleToolbarSendPrompt = useCallback( + const buildSelectionContext = + useCallback((): PromptContextOverride | null => { + if (!selectionRange || !selectionLabel || !activeFile?.content) { + return null; + } + const start = Math.min(selectionRange.start, selectionRange.end); + const end = Math.max(selectionRange.start, selectionRange.end); + const selectedText = activeFile.content.slice(start, end); + if (!selectedText) return null; + return { + label: selectionLabel, + filePath: activeFile.relativePath, + selectedText, + }; + }, [selectionRange, selectionLabel, activeFile]); + + const sendToolbarPromptWithSelectionContext = useCallback( (prompt: string) => { + const context = buildSelectionContext(); toolbarStickyRef.current = false; setSelectionCoords(null); setSelectionRange(null); - useClaudeChatStore.getState().sendPrompt(prompt); + const chat = useClaudeChatStore.getState(); + if (context) { + void chat.sendPrompt(prompt, context); + chat.requestPinnedContextRemoval([context.label]); + } else { + void chat.sendPrompt(prompt); + } }, - [setSelectionRange], + [buildSelectionContext, setSelectionRange], + ); + + const handleToolbarSendPrompt = useCallback( + (prompt: string) => { + sendToolbarPromptWithSelectionContext(prompt); + }, + [sendToolbarPromptWithSelectionContext], ); const editorToolbarActions: ToolbarAction[] = useMemo( @@ -1009,16 +1048,13 @@ export function LatexEditor() { const handleToolbarAction = useCallback( (actionId: string) => { - toolbarStickyRef.current = false; - setSelectionCoords(null); - setSelectionRange(null); if (actionId === "proofread") { - useClaudeChatStore - .getState() - .sendPrompt("Proofread and fix any errors in this text"); + sendToolbarPromptWithSelectionContext( + "Proofread and fix any errors in this text", + ); } }, - [setSelectionRange], + [sendToolbarPromptWithSelectionContext], ); const handleToolbarDismiss = useCallback(() => { @@ -1064,7 +1100,7 @@ export function LatexEditor() { const isImage = !isTextFile && !isPdf && !!activeFile; return ( -
+
{/* Toolbar — adapts to file type */} void; onClose: () => void; - onFindNext: () => void; - onFindPrevious: () => void; + onFindNext: (options?: { focusEditor?: boolean }) => void; + onFindPrevious: (options?: { focusEditor?: boolean }) => void; matchCount: number; currentMatch: number; } @@ -29,14 +29,22 @@ export function SearchPanel({ inputRef.current?.select(); }, []); + const keepInputFocused = () => { + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); if (e.shiftKey) { - onFindPrevious(); + onFindPrevious({ focusEditor: false }); } else { - onFindNext(); + onFindNext({ focusEditor: false }); } + keepInputFocused(); } else if (e.key === "Escape") { e.preventDefault(); onClose(); @@ -44,7 +52,7 @@ export function SearchPanel({ }; return ( -
+
onSearchQueryChange(e.target.value)} onKeyDown={handleKeyDown} placeholder="Search..." - className="h-6 w-48 bg-[#1e2127] text-[#abb2bf] text-sm placeholder:text-[#636d83]" + className="h-6 w-48 border-border bg-muted/40 text-foreground text-sm placeholder:text-muted-foreground" />
{searchQuery && ( - + {matchCount === 0 ? "No results" : `${currentMatch} of ${matchCount}`} )} @@ -83,7 +91,7 @@ export function SearchPanel({ -
+
diff --git a/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx b/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx index f5738b4..e7ad67f 100644 --- a/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx +++ b/apps/desktop/src/components/workspace/preview/pdf-viewer.tsx @@ -1,4 +1,10 @@ -import { useCallback, useRef, useEffect, useState } from "react"; +import { + useCallback, + useRef, + useEffect, + useLayoutEffect, + useState, +} from "react"; import { LoaderIcon } from "lucide-react"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; import { ask } from "@tauri-apps/plugin-dialog"; @@ -14,6 +20,159 @@ import type { PageSize } from "@/lib/mupdf/types"; const log = createLogger("pdf-viewer"); +const MIN_PDF_SCALE = 0.25; +const MAX_PDF_SCALE = 4; +const MOUSE_WHEEL_ZOOM_SENSITIVITY = 0.00125; +const TRACKPAD_PINCH_ZOOM_SENSITIVITY = 0.012; +const MAX_MOUSE_WHEEL_FACTOR_PER_EVENT = 1.22; +const MAX_TRACKPAD_PINCH_FACTOR_PER_EVENT = 1.28; + +type WebKitGestureEvent = Event & { + scale?: number; + clientX?: number; + clientY?: number; +}; + +function clampPdfScale(value: number): number { + return Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, value)); +} + +function isModifiedZoomWheel(event: WheelEvent): boolean { + return (event.metaKey || event.ctrlKey) && !event.altKey; +} + +function isWheelInsidePdfViewer( + event: WheelEvent, + container: HTMLElement, +): boolean { + if (event.target instanceof Node && container.contains(event.target)) { + return true; + } + + if ( + event + .composedPath() + .some((target) => target instanceof Node && container.contains(target)) + ) { + return true; + } + + const rect = container.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); +} + +function isTrackpadPinchWheel( + event: WheelEvent, + isCtrlKeyDown: boolean, + isMetaKeyDown: boolean, +): boolean { + return ( + event.ctrlKey && + !event.metaKey && + !isCtrlKeyDown && + !isMetaKeyDown && + event.deltaMode === WheelEvent.DOM_DELTA_PIXEL && + Math.abs(event.deltaX) < 1 && + event.deltaZ === 0 + ); +} + +function clampZoomFactor(factor: number, maxFactor: number): number { + if (!Number.isFinite(factor) || factor <= 0) return 1; + return Math.max(1 / maxFactor, Math.min(maxFactor, factor)); +} + +function getWheelZoomFactor( + event: WheelEvent, + isTrackpadPinch: boolean, +): number { + if (event.deltaY === 0) return 1; + + if ( + !isTrackpadPinch && + (event.deltaMode === WheelEvent.DOM_DELTA_LINE || + event.deltaMode === WheelEvent.DOM_DELTA_PAGE) + ) { + return event.deltaY < 0 ? 1.16 : 1 / 1.16; + } + + const sensitivity = isTrackpadPinch + ? TRACKPAD_PINCH_ZOOM_SENSITIVITY + : MOUSE_WHEEL_ZOOM_SENSITIVITY; + const maxFactor = isTrackpadPinch + ? MAX_TRACKPAD_PINCH_FACTOR_PER_EVENT + : MAX_MOUSE_WHEEL_FACTOR_PER_EVENT; + + return clampZoomFactor(Math.exp(-event.deltaY * sensitivity), maxFactor); +} + +interface PageZoomAnchor { + pageNumber: number; + pdfX: number; + pdfY: number; +} + +interface PendingZoomScroll { + anchorClientX: number; + anchorClientY: number; + pageAnchor: PageZoomAnchor | null; + fallbackAnchorX: number; + fallbackAnchorY: number; + fallbackRatio: number; + nextScale: number; + containerLeft: number; + containerTop: number; +} + +function findPageZoomAnchor( + container: HTMLElement, + clientX: number, + clientY: number, + scale: number, +): PageZoomAnchor | null { + if (scale <= 0) return null; + + const pages = Array.from(container.querySelectorAll(".mupdf-page")); + let bestPage: HTMLElement | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const page of pages) { + const el = page as HTMLElement; + const rect = el.getBoundingClientRect(); + const distance = + clientY >= rect.top && clientY <= rect.bottom + ? 0 + : Math.min( + Math.abs(clientY - rect.top), + Math.abs(clientY - rect.bottom), + ); + + if (distance < bestDistance) { + bestDistance = distance; + bestPage = el; + } + } + + if (!bestPage) return null; + + const pageNumber = parseInt( + bestPage.getAttribute("data-page-number") || "0", + 10, + ); + if (!pageNumber) return null; + + const rect = bestPage.getBoundingClientRect(); + return { + pageNumber, + pdfX: (clientX - rect.left) / scale, + pdfY: (clientY - rect.top) / scale, + }; +} /** Module-level scroll position cache: rootFileId → page number */ const scrollPositionCache = new Map(); @@ -89,6 +248,17 @@ export function PdfViewer({ const scaleRef = useRef(scale); scaleRef.current = scale; + const renderedScaleRef = useRef(scale); + const pendingZoomScrollRef = useRef(null); + const ctrlKeyDownRef = useRef(false); + const metaKeyDownRef = useRef(false); + const touchPinchRef = useRef<{ + distance: number; + scale: number; + x: number; + y: number; + } | null>(null); + const gesturePinchRef = useRef<{ scale: number } | null>(null); const synctexClickRef = useRef(onSynctexClick); synctexClickRef.current = onSynctexClick; const textSelectRef = useRef(onTextSelect); @@ -107,6 +277,42 @@ export function PdfViewer({ window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore); }, []); + useLayoutEffect(() => { + renderedScaleRef.current = scale; + + const pending = pendingZoomScrollRef.current; + if (!pending || Math.abs(pending.nextScale - scale) > 0.001) return; + pendingZoomScrollRef.current = null; + + const container = containerRef.current; + if (!container) return; + + if (pending.pageAnchor) { + const pageEl = container.querySelector( + `.mupdf-page[data-page-number="${pending.pageAnchor.pageNumber}"]`, + ) as HTMLElement | null; + if (pageEl) { + const pageRect = pageEl.getBoundingClientRect(); + container.scrollLeft += + pageRect.left + + pending.pageAnchor.pdfX * scale - + pending.anchorClientX; + container.scrollTop += + pageRect.top + + pending.pageAnchor.pdfY * scale - + pending.anchorClientY; + return; + } + } + + container.scrollLeft = + pending.fallbackAnchorX * pending.fallbackRatio - + (pending.anchorClientX - pending.containerLeft); + container.scrollTop = + pending.fallbackAnchorY * pending.fallbackRatio - + (pending.anchorClientY - pending.containerTop); + }, [scale]); + // Keep-alive scroll save/restore const savedScrollTop = useRef(0); const prevIsActive = useRef(isActive); @@ -140,6 +346,30 @@ export function PdfViewer({ const numPages = pageSizes.length; + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Control") ctrlKeyDownRef.current = true; + if (event.key === "Meta") metaKeyDownRef.current = true; + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === "Control") ctrlKeyDownRef.current = false; + if (event.key === "Meta") metaKeyDownRef.current = false; + }; + const resetModifierState = () => { + ctrlKeyDownRef.current = false; + metaKeyDownRef.current = false; + }; + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("blur", resetModifierState); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("blur", resetModifierState); + }; + }, []); + function getVisiblePage(): number { const container = containerRef.current; if (!container) return 1; @@ -339,7 +569,7 @@ export function PdfViewer({ pages.forEach((p) => observer.observe(p)); return () => observer.disconnect(); - }, [pageSizes, scale, isActive, focusGen]); + }, [pageSizes, isActive, focusGen]); // Report container dimensions to parent for fit-to-width/height useEffect(() => { @@ -516,24 +746,199 @@ export function PdfViewer({ return () => container.removeEventListener("scroll", handleScroll); }, []); - // Pinch-to-zoom + const zoomAtPoint = useCallback( + (nextScaleValue: number, clientX?: number, clientY?: number) => { + if (!onScaleChange) return; + + const container = containerRef.current; + const previousScale = scaleRef.current; + const renderedScale = renderedScaleRef.current; + const nextScale = clampPdfScale(nextScaleValue); + if (Math.abs(nextScale - previousScale) < 0.001) return; + + if (!container || previousScale <= 0 || renderedScale <= 0) { + scaleRef.current = nextScale; + onScaleChange(nextScale); + return; + } + + const containerRect = container.getBoundingClientRect(); + const anchorClientX = + clientX ?? containerRect.left + containerRect.width / 2; + const anchorClientY = + clientY ?? containerRect.top + containerRect.height / 2; + const pageAnchor = findPageZoomAnchor( + container, + anchorClientX, + anchorClientY, + renderedScale, + ); + const fallbackAnchorX = + anchorClientX - containerRect.left + container.scrollLeft; + const fallbackAnchorY = + anchorClientY - containerRect.top + container.scrollTop; + const fallbackRatio = nextScale / renderedScale; + + pendingZoomScrollRef.current = { + anchorClientX, + anchorClientY, + pageAnchor, + fallbackAnchorX, + fallbackAnchorY, + fallbackRatio, + nextScale, + containerLeft: containerRect.left, + containerTop: containerRect.top, + }; + + scaleRef.current = nextScale; + onScaleChange(nextScale); + }, + [onScaleChange], + ); + + // Ctrl/Cmd + wheel zoom. Windows precision touchpad pinch arrives here too. useEffect(() => { const container = containerRef.current; if (!container || !onScaleChange) return; const handleWheel = (e: WheelEvent) => { - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - const delta = -e.deltaY * 0.005; - onScaleChange(Math.max(0.25, Math.min(4, scale + delta))); + if (!isModifiedZoomWheel(e) || !isWheelInsidePdfViewer(e, container)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const isTrackpadPinch = isTrackpadPinchWheel( + e, + ctrlKeyDownRef.current, + metaKeyDownRef.current, + ); + const factor = getWheelZoomFactor(e, isTrackpadPinch); + if (Math.abs(factor - 1) < 0.0001) return; + zoomAtPoint(scaleRef.current * factor, e.clientX, e.clientY); + }; + + window.addEventListener("wheel", handleWheel, { + passive: false, + }); + return () => { + window.removeEventListener("wheel", handleWheel); + pendingZoomScrollRef.current = null; + }; + }, [onScaleChange, zoomAtPoint]); + + // Some WebViews report trackpad pinch through non-standard WebKit gesture + // events instead of Ctrl/Cmd + wheel. + useEffect(() => { + const container = containerRef.current; + if (!container || !onScaleChange) return; + + const readGestureScale = (event: WebKitGestureEvent) => + typeof event.scale === "number" && Number.isFinite(event.scale) + ? event.scale + : 1; + + const handleGestureStart: EventListener = (event) => { + event.preventDefault(); + gesturePinchRef.current = { scale: scaleRef.current }; + }; + + const handleGestureChange: EventListener = (event) => { + const gesture = event as WebKitGestureEvent; + const start = gesturePinchRef.current; + if (!start) return; + + event.preventDefault(); + const factor = + (start.scale * readGestureScale(gesture)) / scaleRef.current; + zoomAtPoint(scaleRef.current * factor, gesture.clientX, gesture.clientY); + }; + + const handleGestureEnd: EventListener = () => { + gesturePinchRef.current = null; + }; + + container.addEventListener("gesturestart", handleGestureStart, { + passive: false, + }); + container.addEventListener("gesturechange", handleGestureChange, { + passive: false, + }); + container.addEventListener("gestureend", handleGestureEnd); + return () => { + container.removeEventListener("gesturestart", handleGestureStart); + container.removeEventListener("gesturechange", handleGestureChange); + container.removeEventListener("gestureend", handleGestureEnd); + gesturePinchRef.current = null; + }; + }, [onScaleChange, zoomAtPoint]); + + // Two-finger touch pinch for touch-capable screens. Trackpads usually arrive + // through the wheel path above. + useEffect(() => { + const container = containerRef.current; + if (!container || !onScaleChange) return; + + const readTouchPinch = (touches: TouchList) => { + if (touches.length < 2) return null; + const first = touches.item(0); + const second = touches.item(1); + if (!first || !second) return null; + const dx = second.clientX - first.clientX; + const dy = second.clientY - first.clientY; + return { + distance: Math.hypot(dx, dy), + x: (first.clientX + second.clientX) / 2, + y: (first.clientY + second.clientY) / 2, + }; + }; + + const handleTouchStart = (event: TouchEvent) => { + const pinch = readTouchPinch(event.touches); + if (!pinch) return; + event.preventDefault(); + touchPinchRef.current = { + ...pinch, + scale: scaleRef.current, + }; + }; + + const handleTouchMove = (event: TouchEvent) => { + const start = touchPinchRef.current; + const pinch = readTouchPinch(event.touches); + if (!start || !pinch || start.distance <= 0) return; + event.preventDefault(); + const factor = + (start.scale * (pinch.distance / start.distance)) / scaleRef.current; + zoomAtPoint(scaleRef.current * factor, pinch.x, pinch.y); + }; + + const handleTouchEnd = () => { + if (containerRef.current && touchPinchRef.current) { + touchPinchRef.current = null; } }; - container.addEventListener("wheel", handleWheel, { passive: false }); - return () => container.removeEventListener("wheel", handleWheel); - }, [scale, onScaleChange]); + container.addEventListener("touchstart", handleTouchStart, { + passive: false, + }); + container.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + container.addEventListener("touchend", handleTouchEnd); + container.addEventListener("touchcancel", handleTouchEnd); + return () => { + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + container.removeEventListener("touchcancel", handleTouchEnd); + touchPinchRef.current = null; + }; + }, [onScaleChange, zoomAtPoint]); - // Keyboard zoom (Cmd/Ctrl +/-) — scoped to container to avoid affecting other panels + // Keyboard zoom (Cmd/Ctrl +/-), scoped to the PDF viewer. useEffect(() => { const container = containerRef.current; if (!container || !onScaleChange) return; @@ -544,19 +949,19 @@ export function PdfViewer({ if (e.key === "=" || e.key === "+") { e.preventDefault(); - onScaleChange(Math.min(4, scale + 0.25)); + zoomAtPoint(scaleRef.current + 0.25); } else if (e.key === "-") { e.preventDefault(); - onScaleChange(Math.max(0.25, scale - 0.25)); + zoomAtPoint(scaleRef.current - 0.25); } else if (e.key === "0") { e.preventDefault(); - onScaleChange(1); + zoomAtPoint(1); } }; container.addEventListener("keydown", handleKeyDown); return () => container.removeEventListener("keydown", handleKeyDown); - }, [scale, onScaleChange]); + }, [onScaleChange, zoomAtPoint]); // Intercept link clicks useEffect(() => { @@ -754,7 +1159,10 @@ export function PdfViewer({ tabIndex={-1} {...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }} className="min-h-0 flex-1 overflow-auto outline-none" - style={{ cursor: captureMode ? "crosshair" : undefined }} + style={{ + cursor: captureMode ? "crosshair" : undefined, + touchAction: captureMode ? "none" : "pan-x pan-y", + }} onMouseDownCapture={() => containerRef.current?.focus()} onMouseDown={handleCaptureMouseDown} onMouseMove={handleCaptureMouseMove} diff --git a/apps/desktop/src/components/workspace/sidebar.tsx b/apps/desktop/src/components/workspace/sidebar.tsx index 413cbfc..79485c7 100644 --- a/apps/desktop/src/components/workspace/sidebar.tsx +++ b/apps/desktop/src/components/workspace/sidebar.tsx @@ -17,6 +17,7 @@ import { ListIcon, HashIcon, GithubIcon, + PanelLeftIcon, ChevronRightIcon, ChevronDownIcon, FileCodeIcon, @@ -25,6 +26,7 @@ import { AppWindowIcon, FlaskConicalIcon, TerminalIcon, + type LucideIcon, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { @@ -48,6 +50,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogFooter, @@ -66,6 +69,11 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; import { Input } from "@/components/ui/input"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { getCurrentWebview } from "@tauri-apps/api/webview"; @@ -74,6 +82,8 @@ import { UvSetupDialog } from "@/components/uv-setup"; import { createLogger } from "@/lib/debug/logger"; const log = createLogger("sidebar"); +const FILES_AUTO_REFRESH_INTERVAL_MS = 12_000; +const FILES_REFRESH_MIN_SPIN_MS = 400; // ─── Table of Contents ─── @@ -119,6 +129,60 @@ interface TreeNode { children: TreeNode[]; } +type FileTreeItemType = "file" | "folder"; + +interface FileTreeSelectionItem { + type: FileTreeItemType; + path: string; +} + +function fileTreeSelectionKey(item: FileTreeSelectionItem) { + return `${item.type}:${item.path}`; +} + +function fileTreeSelectionItemFromKey( + key: string, +): FileTreeSelectionItem | null { + const separator = key.indexOf(":"); + if (separator === -1) return null; + + const type = key.slice(0, separator); + const path = key.slice(separator + 1); + if ((type !== "file" && type !== "folder") || !path) return null; + + return { type, path }; +} + +function isInsideFolder(path: string, folderPath: string) { + return path === folderPath || path.startsWith(`${folderPath}/`); +} + +function parentFolderOfPath(path: string): string | undefined { + return path.includes("/") + ? path.substring(0, path.lastIndexOf("/")) + : undefined; +} + +function normalizeSelectionItems(items: FileTreeSelectionItem[]) { + const folders = items + .filter((item) => item.type === "folder") + .sort((a, b) => a.path.length - b.path.length) + .filter( + (item, index, all) => + !all + .slice(0, index) + .some((folder) => isInsideFolder(item.path, folder.path)), + ); + + const files = items.filter( + (item) => + item.type === "file" && + !folders.some((folder) => isInsideFolder(item.path, folder.path)), + ); + + return { files, folders }; +} + function buildFileTree(files: ProjectFile[], folders: string[]): TreeNode[] { const root: TreeNode[] = []; const folderMap = new Map(); @@ -206,7 +270,141 @@ function useAppVersion() { // ─── Sidebar ─── -export function Sidebar() { +function LayoutPaneSwitcher({ + controls, + collapsed = false, + onQuickToggleSidebar, + side = "bottom", + align = "end", + buttonClassName, +}: { + controls?: LayoutControls; + collapsed?: boolean; + onQuickToggleSidebar?: () => void; + side?: "top" | "right" | "bottom" | "left"; + align?: "start" | "center" | "end"; + buttonClassName?: string; +}) { + const trigger = ( + + ); + + if (!controls) return trigger; + + return ( + + {trigger} + +
+ + + +
+
+
+ ); +} + +function LayoutToggleRow({ + icon: Icon, + label, + checked, + onCheckedChange, +}: { + icon: LucideIcon; + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +interface SidebarProps { + collapsed?: boolean; + onToggleCollapsed?: () => void; + layoutControls?: LayoutControls; +} + +interface LayoutControls { + codeVisible: boolean; + pdfVisible: boolean; + sidebarVisible: boolean; + setCodeVisible: (visible: boolean) => void; + setPdfVisible: (visible: boolean) => void; + setSidebarVisible: (visible: boolean) => void; +} + +export function Sidebar({ + collapsed = false, + onToggleCollapsed, + layoutControls, +}: SidebarProps) { const appVersion = useAppVersion(); const files = useDocumentStore((s) => s.files); const activeFileId = useDocumentStore((s) => s.activeFileId); @@ -214,6 +412,7 @@ export function Sidebar() { const deleteFile = useDocumentStore((s) => s.deleteFile); const deleteFolder = useDocumentStore((s) => s.deleteFolder); const renameFile = useDocumentStore((s) => s.renameFile); + const renameProject = useDocumentStore((s) => s.renameProject); const createNewFile = useDocumentStore((s) => s.createNewFile); const createFolder = useDocumentStore((s) => s.createFolder); const importFiles = useDocumentStore((s) => s.importFiles); @@ -231,7 +430,72 @@ export function Sidebar() { const refreshFiles = useDocumentStore((s) => s.refreshFiles); const projectRoot = useDocumentStore((s) => s.projectRoot); const folders = useDocumentStore((s) => s.folders); + const [isRefreshingFiles, setIsRefreshingFiles] = useState(false); + const refreshFilesInFlightRef = useRef | null>(null); const { theme, setTheme } = useTheme(); + const projectName = useMemo(() => { + const normalized = projectRoot?.replace(/[\\/]+$/, ""); + return normalized?.split(/[/\\]/).pop() || "Desktop"; + }, [projectRoot]); + + const runRefreshFiles = useCallback( + async ({ showSpinner = true }: { showSpinner?: boolean } = {}) => { + if (!projectRoot) return; + if (refreshFilesInFlightRef.current) { + await refreshFilesInFlightRef.current; + return; + } + + const startedAt = Date.now(); + if (showSpinner) setIsRefreshingFiles(true); + + const refreshTask = (async () => { + try { + await refreshFiles(); + } catch (err) { + log.error("Refresh files failed", { error: String(err) }); + } finally { + if (showSpinner) { + const elapsed = Date.now() - startedAt; + if (elapsed < FILES_REFRESH_MIN_SPIN_MS) { + await new Promise((resolve) => + window.setTimeout(resolve, FILES_REFRESH_MIN_SPIN_MS - elapsed), + ); + } + setIsRefreshingFiles(false); + } + refreshFilesInFlightRef.current = null; + } + })(); + + refreshFilesInFlightRef.current = refreshTask; + await refreshTask; + }, + [projectRoot, refreshFiles], + ); + + useEffect(() => { + if (!projectRoot) return; + + const refreshIfVisible = () => { + if (document.visibilityState === "visible") { + void runRefreshFiles({ showSpinner: false }); + } + }; + + const intervalId = window.setInterval( + refreshIfVisible, + FILES_AUTO_REFRESH_INTERVAL_MS, + ); + window.addEventListener("focus", refreshIfVisible); + document.addEventListener("visibilitychange", refreshIfVisible); + + return () => { + window.clearInterval(intervalId); + window.removeEventListener("focus", refreshIfVisible); + document.removeEventListener("visibilitychange", refreshIfVisible); + }; + }, [projectRoot, runRefreshFiles]); // ─── Native OS file drop (Tauri onDragDropEvent) ─── const sidebarFilesRef = useRef(null); @@ -366,6 +630,220 @@ export function Sidebar() { return () => window.removeEventListener("keydown", handleKeyDown); }, [importFiles, pasteTargetFolder]); + const [selectedItemKeys, setSelectedItemKeys] = useState>( + new Set(), + ); + + const existingItemKeys = useMemo(() => { + const keys = new Set(); + for (const file of files) { + keys.add(fileTreeSelectionKey({ type: "file", path: file.relativePath })); + } + for (const folder of folders) { + keys.add(fileTreeSelectionKey({ type: "folder", path: folder })); + } + return keys; + }, [files, folders]); + + useEffect(() => { + setSelectedItemKeys((prev) => { + const next = new Set( + [...prev].filter((key) => existingItemKeys.has(key)), + ); + return next.size === prev.size ? prev : next; + }); + }, [existingItemKeys]); + + const selectedItemsFromKeys = useCallback( + (keys: Iterable) => + Array.from(keys) + .map(fileTreeSelectionItemFromKey) + .filter((item): item is FileTreeSelectionItem => { + if (!item) return false; + return existingItemKeys.has(fileTreeSelectionKey(item)); + }), + [existingItemKeys], + ); + + const getEffectiveSelectionItems = useCallback( + (fallback: FileTreeSelectionItem) => { + const fallbackKey = fileTreeSelectionKey(fallback); + if (selectedItemKeys.has(fallbackKey)) { + return selectedItemsFromKeys(selectedItemKeys); + } + return [fallback]; + }, + [selectedItemKeys, selectedItemsFromKeys], + ); + + const getEffectiveSelectionCount = useCallback( + (fallback: FileTreeSelectionItem) => + getEffectiveSelectionItems(fallback).length, + [getEffectiveSelectionItems], + ); + + const selectedAffectedFileCount = useCallback( + (items: FileTreeSelectionItem[]) => { + const { files: selectedFiles, folders: selectedFolders } = + normalizeSelectionItems(items); + const affected = new Set(selectedFiles.map((item) => item.path)); + + for (const folder of selectedFolders) { + for (const file of files) { + if (isInsideFolder(file.relativePath, folder.path)) { + affected.add(file.relativePath); + } + } + } + + return affected.size; + }, + [files], + ); + + const canDeleteSelection = useCallback( + (fallback: FileTreeSelectionItem) => { + const items = getEffectiveSelectionItems(fallback); + const affected = selectedAffectedFileCount(items); + return items.length > 0 && affected < files.length; + }, + [files.length, getEffectiveSelectionItems, selectedAffectedFileCount], + ); + + const [pendingDeleteItems, setPendingDeleteItems] = useState< + FileTreeSelectionItem[] | null + >(null); + const [isDeletingSelection, setIsDeletingSelection] = useState(false); + const [deleteError, setDeleteError] = useState(""); + + const canDeleteItems = useCallback( + (items: FileTreeSelectionItem[]) => { + const affected = selectedAffectedFileCount(items); + return items.length > 0 && affected < files.length; + }, + [files.length, selectedAffectedFileCount], + ); + + const requestDeleteItems = useCallback( + (items: FileTreeSelectionItem[]) => { + if (!canDeleteItems(items)) return; + const { files: selectedFiles, folders: selectedFolders } = + normalizeSelectionItems(items); + setPendingDeleteItems([...selectedFolders, ...selectedFiles]); + setDeleteError(""); + }, + [canDeleteItems], + ); + + const handleTreeItemContextMenu = useCallback( + (item: FileTreeSelectionItem) => { + const key = fileTreeSelectionKey(item); + setPasteTargetFolder( + item.type === "folder" ? item.path : parentFolderOfPath(item.path), + ); + setSelectedItemKeys((prev) => (prev.has(key) ? prev : new Set([key]))); + }, + [], + ); + + const handleTreeItemClick = useCallback( + ( + item: FileTreeSelectionItem, + event: React.MouseEvent, + onPrimaryClick: () => void, + ) => { + setPasteTargetFolder( + item.type === "folder" ? item.path : parentFolderOfPath(item.path), + ); + + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + event.stopPropagation(); + const key = fileTreeSelectionKey(item); + setSelectedItemKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + return; + } + + setSelectedItemKeys(new Set()); + onPrimaryClick(); + }, + [], + ); + + const requestDeleteSelection = useCallback( + (fallback: FileTreeSelectionItem) => { + requestDeleteItems(getEffectiveSelectionItems(fallback)); + }, + [getEffectiveSelectionItems, requestDeleteItems], + ); + + const confirmDeleteSelection = useCallback(async () => { + if (!pendingDeleteItems || isDeletingSelection) return; + + setIsDeletingSelection(true); + setDeleteError(""); + try { + const { files: selectedFiles, folders: selectedFolders } = + normalizeSelectionItems(pendingDeleteItems); + + for (const file of selectedFiles) { + await Promise.resolve(deleteFile(file.path) as unknown); + } + for (const folder of selectedFolders) { + await deleteFolder(folder.path); + } + + setPendingDeleteItems(null); + setSelectedItemKeys(new Set()); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : String(err)); + } finally { + setIsDeletingSelection(false); + } + }, [deleteFile, deleteFolder, isDeletingSelection, pendingDeleteItems]); + + useEffect(() => { + const handleDeleteKey = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + + const active = document.activeElement; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + (active as HTMLElement).isContentEditable) + ) { + return; + } + + const selectedItems = selectedItemsFromKeys(selectedItemKeys); + if (selectedItems.length > 0) { + event.preventDefault(); + requestDeleteItems(selectedItems); + return; + } + + if (activeFileId) { + event.preventDefault(); + requestDeleteItems([{ type: "file", path: activeFileId }]); + } + }; + + window.addEventListener("keydown", handleDeleteKey); + return () => window.removeEventListener("keydown", handleDeleteKey); + }, [ + activeFileId, + requestDeleteItems, + selectedItemKeys, + selectedItemsFromKeys, + ]); + // dnd-kit drag-and-drop (uses PointerSensor — works in Tauri WKWebView) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -374,15 +852,29 @@ export function Sidebar() { id: string; type: "file" | "folder"; name: string; + count: number; } | null>(null); - const handleDragStart = useCallback((event: DragStartEvent) => { - const { type, name } = event.active.data.current as { - type: "file" | "folder"; - name: string; - }; - setActiveDrag({ id: event.active.id as string, type, name }); - }, []); + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { type, name } = event.active.data.current as { + type: "file" | "folder"; + name: string; + }; + const item: FileTreeSelectionItem = { + type, + path: event.active.id as string, + }; + const key = fileTreeSelectionKey(item); + setActiveDrag({ + id: item.path, + type, + name, + count: selectedItemKeys.has(key) ? getEffectiveSelectionCount(item) : 1, + }); + }, + [getEffectiveSelectionCount, selectedItemKeys], + ); const handleDragEnd = useCallback( async (event: DragEndEvent) => { @@ -395,29 +887,45 @@ export function Sidebar() { const targetId = over.id as string; const targetFolder = targetId === "__root__" ? null : targetId; - // Don't move if same parent - const draggedParent = draggedPath.includes("/") - ? draggedPath.substring(0, draggedPath.lastIndexOf("/")) - : null; - if (targetFolder === draggedParent) return; + const draggedItem: FileTreeSelectionItem = { + type: draggedType === "folder" ? "folder" : "file", + path: draggedPath, + }; + const draggedKey = fileTreeSelectionKey(draggedItem); + const movingItems = selectedItemKeys.has(draggedKey) + ? selectedItemsFromKeys(selectedItemKeys) + : [draggedItem]; + const { files: movingFiles, folders: movingFolders } = + normalizeSelectionItems(movingItems); - // Don't move folder into itself or descendant - if (draggedType === "folder" && targetFolder) { - if ( - targetFolder === draggedPath || - targetFolder.startsWith(`${draggedPath}/`) + if ( + targetFolder && + movingFolders.some((folder) => + isInsideFolder(targetFolder, folder.path), ) - return; + ) { + return; } try { - if (draggedType === "file") await moveFile(draggedPath, targetFolder); - else await moveFolder(draggedPath, targetFolder); + for (const file of movingFiles) { + const parent = parentFolderOfPath(file.path) ?? null; + if (targetFolder === parent) continue; + await moveFile(file.path, targetFolder); + } + + for (const folder of movingFolders) { + const parent = parentFolderOfPath(folder.path) ?? null; + if (targetFolder === parent) continue; + await moveFolder(folder.path, targetFolder); + } + + setSelectedItemKeys(new Set()); } catch (err) { log.error("DnD move failed", { error: String(err) }); } }, - [moveFile, moveFolder], + [moveFile, moveFolder, selectedItemKeys, selectedItemsFromKeys], ); // Dialog state @@ -430,6 +938,10 @@ export function Sidebar() { const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [renameFileId, setRenameFileId] = useState(null); const [renameValue, setRenameValue] = useState(""); + const [projectRenameDialogOpen, setProjectRenameDialogOpen] = useState(false); + const [projectRenameValue, setProjectRenameValue] = useState(""); + const [projectRenameError, setProjectRenameError] = useState(""); + const [isRenamingProject, setIsRenamingProject] = useState(false); const [newFileName, setNewFileName] = useState(""); const [newFolderName, setNewFolderName] = useState(""); @@ -544,28 +1056,6 @@ export function Sidebar() { const handleImport = async (targetFolder?: string) => { const selected = await openDialog({ multiple: true, - filters: [ - { - name: "All Files", - extensions: [ - "tex", - "bib", - "sty", - "cls", - "bst", - "png", - "jpg", - "jpeg", - "gif", - "svg", - "bmp", - "webp", - "pdf", - "txt", - "md", - ], - }, - ], }); if (selected && projectRoot) { const paths = Array.isArray(selected) ? selected : [selected]; @@ -580,6 +1070,28 @@ export function Sidebar() { setRenameDialogOpen(true); }; + const openProjectRenameDialog = () => { + if (!projectRoot) return; + setProjectRenameValue(projectName); + setProjectRenameError(""); + setProjectRenameDialogOpen(true); + }; + + const handleProjectRename = async () => { + const name = projectRenameValue.trim(); + if (!name || isRenamingProject) return; + setIsRenamingProject(true); + setProjectRenameError(""); + try { + await renameProject(name); + setProjectRenameDialogOpen(false); + } catch (err) { + setProjectRenameError(err instanceof Error ? err.message : String(err)); + } finally { + setIsRenamingProject(false); + } + }; + const handleRename = () => { const name = renameValue.trim(); if (!renameFileId || !name) return; @@ -618,351 +1130,603 @@ export function Sidebar() { // ─── Render ─── - return ( -
- {/* Header — padded top for macOS overlay titlebar */} -
-
- ClaudePrism - - {projectRoot?.split(/[/\\]/).pop() || "Desktop"} - -
-
- -
-
+ const pendingDeleteCount = pendingDeleteItems?.length ?? 0; + const pendingDeletePreview = pendingDeleteItems?.slice(0, 4) ?? []; - {/* Resizable sections */} - - {/* Files */} - -
-
-
- - Files -
-
- - - + const collapsedRail = ( +
+
+ +
+
+ + + + +
+
+ +
+
+ ); + + return ( +
+
+ {collapsedRail} +
+
+
+ {/* Header — padded top for macOS overlay titlebar */} +
+
+ +
+ +
+ +
+
+ + {/* Resizable sections */} + + {/* Files */} + +
+
+
+ + Files +
+
- - - openNewFileDialog()}> - - New LaTeX File - - openNewFolderDialog()}> - - New Folder - - - handleImport()}> - - Import File - - - -
-
- - - - - {tree.map((node) => ( - { - const parent = id.includes("/") - ? id.substring(0, id.lastIndexOf("/")) - : undefined; - setPasteTargetFolder(parent); - setActiveFile(id); - }} - onNewFile={openNewFileDialog} - onNewFolder={openNewFolderDialog} - onImport={handleImport} - onRename={openRenameDialog} - onDelete={deleteFile} - onDeleteFolder={deleteFolder} - fileCount={files.length} - nativeDragOver={nativeDragOver} + - ))} - - - - openNewFileDialog()}> - - New File - - openNewFolderDialog()}> - - New Folder - - - handleImport()}> - - Import File - - - - - {activeDrag && ( -
- {activeDrag.type === "folder" ? ( - - ) : ( - - )} - {activeDrag.name} + + + + + + + openNewFileDialog()}> + + New LaTeX File + + openNewFolderDialog()}> + + New Folder + + + handleImport()}> + + Import File + + +
+
+ + + + + {tree.map((node) => ( + { + const parent = parentFolderOfPath(id); + setPasteTargetFolder(parent); + setActiveFile(id); + }} + onItemClick={handleTreeItemClick} + onItemContextMenu={handleTreeItemContextMenu} + onNewFile={openNewFileDialog} + onNewFolder={openNewFolderDialog} + onImport={handleImport} + onRename={openRenameDialog} + onDeleteSelection={requestDeleteSelection} + canDeleteSelection={canDeleteSelection} + getEffectiveSelectionCount={ + getEffectiveSelectionCount + } + nativeDragOver={nativeDragOver} + /> + ))} + + + + openNewFileDialog()}> + + New File + + openNewFolderDialog()}> + + New Folder + + + handleImport()}> + + Import File + + + + + {activeDrag && ( +
+ {activeDrag.type === "folder" ? ( + + ) : ( + + )} + + {activeDrag.count > 1 + ? `${activeDrag.count} selected` + : activeDrag.name} + +
+ )} +
+
+
+ + + + + {/* Outline */} + +
+
+ + Outline +
+
+ {toc.length > 0 ? ( + toc.map((item, index) => ( + + )) + ) : ( +
+ No sections found +
+ )} +
+
+
+ + + + {/* Zotero */} + +
+
+ +
+
+ +
+
+
+ + + {/* Environment section — Python + Skills */} + + + {/* Footer */} +
+ ClaudePrism v{appVersion} +
+ +
- - - - - {/* Outline */} - -
-
- - Outline +
-
- {toc.length > 0 ? ( - toc.map((item, index) => ( - - )) - ) : ( -
- No sections found +
+ + {/* New File Dialog */} + + + + + New File{addDialogFolder ? ` in ${addDialogFolder}` : ""} + + +
+ { + setNewFileName(e.target.value); + setNameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleAddFile(); + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + {/* New Folder Dialog */} + + + + + New Folder + {folderDialogParent ? ` in ${folderDialogParent}` : ""} + + +
+ { + setNewFolderName(e.target.value); + setNameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateFolder(); + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + {/* Rename Project Dialog */} + { + if (!isRenamingProject) setProjectRenameDialogOpen(open); + }} + > + + + Rename Project + +
+ { + setProjectRenameValue(e.target.value); + setProjectRenameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleProjectRename(); + }} + autoFocus + /> + {projectRenameError && ( +

+ {projectRenameError} +

+ )} +
+ + + + +
+
+ + {/* Rename Dialog */} + + + + Rename + +
+ { + setRenameValue(e.target.value); + setNameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + {/* Delete confirmation */} + { + if (!open && !isDeletingSelection) { + setPendingDeleteItems(null); + setDeleteError(""); + } + }} + > + + + + Delete {pendingDeleteCount === 1 ? "Item" : "Items"} + + + {pendingDeleteCount === 1 + ? "This item will be removed from disk." + : `${pendingDeleteCount} selected items will be removed from disk.`} + + + {pendingDeletePreview.length > 0 && ( +
+
+ {pendingDeletePreview.map((item) => ( +
+ {item.type === "folder" ? "Folder" : "File"}:{" "} + {item.path} +
+ ))} + {pendingDeleteCount > pendingDeletePreview.length && ( +
+ +{pendingDeleteCount - pendingDeletePreview.length} more +
+ )} +
)} -
-
-
- - - - {/* Zotero */} - -
-
- -
-
- -
-
-
- - - {/* Environment section — Python + Skills */} - - - {/* Footer */} -
- ClaudePrism v{appVersion} -
- - + {deleteError && ( +

+ {deleteError} +

+ )} + + + + + +
- - {/* New File Dialog */} - - - - - New File{addDialogFolder ? ` in ${addDialogFolder}` : ""} - - -
- { - setNewFileName(e.target.value); - setNameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddFile(); - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - -
-
- - {/* New Folder Dialog */} - - - - - New Folder{folderDialogParent ? ` in ${folderDialogParent}` : ""} - - -
- { - setNewFolderName(e.target.value); - setNameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleCreateFolder(); - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - -
-
- - {/* Rename Dialog */} - - - - Rename - -
- { - setRenameValue(e.target.value); - setNameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleRename(); - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - -
-
); } @@ -1020,16 +1784,23 @@ interface FileTreeNodeProps { node: TreeNode; depth: number; activeFileId: string; + selectedItemKeys: Set; expandedFolders: Set; onToggleFolder: (path: string) => void; onSelectFile: (id: string) => void; + onItemClick: ( + item: FileTreeSelectionItem, + event: React.MouseEvent, + onPrimaryClick: () => void, + ) => void; + onItemContextMenu: (item: FileTreeSelectionItem) => void; onNewFile: (folder?: string) => void; onNewFolder: (parent?: string) => void; onImport: (folder?: string) => void; onRename: (id: string, name: string) => void; - onDelete: (id: string) => void; - onDeleteFolder: (folderPath: string) => void; - fileCount: number; + onDeleteSelection: (fallback: FileTreeSelectionItem) => void; + canDeleteSelection: (fallback: FileTreeSelectionItem) => boolean; + getEffectiveSelectionCount: (fallback: FileTreeSelectionItem) => number; nativeDragOver?: string | null; } @@ -1037,21 +1808,32 @@ function FileTreeNode({ node, depth, activeFileId, + selectedItemKeys, expandedFolders, onToggleFolder, onSelectFile, + onItemClick, + onItemContextMenu, onNewFile, onNewFolder, onImport, onRename, - onDelete, - onDeleteFolder, - fileCount, + onDeleteSelection, + canDeleteSelection, + getEffectiveSelectionCount, nativeDragOver, }: FileTreeNodeProps) { const isExpanded = expandedFolders.has(node.relativePath); if (node.type === "folder") { + const folderItem: FileTreeSelectionItem = { + type: "folder", + path: node.relativePath, + }; + const isSelected = selectedItemKeys.has(fileTreeSelectionKey(folderItem)); + const effectiveSelectionCount = getEffectiveSelectionCount(folderItem); + const batchOperation = effectiveSelectionCount > 1; + return ( - onRename(file.id, file.name)}> + onRename(file.id, file.name)} + disabled={batchOperation} + > Rename onDelete(file.id)} - disabled={fileCount <= 1} + onClick={() => onDeleteSelection(fileItem)} + disabled={!canDeleteSelection(fileItem)} > - Delete + {batchOperation + ? `Delete ${effectiveSelectionCount} selected` + : "Delete"} @@ -1184,7 +2002,11 @@ interface SkillsStatus { location: string; } -function EnvironmentSection({ projectPath }: { projectPath: string | null }) { +function EnvironmentSection({ + projectPath: _projectPath, +}: { + projectPath: string | null; +}) { // ── Python / uv ── const venvReady = useUvSetupStore((s) => s.venvReady); const uvStatus = useUvSetupStore((s) => s.status); @@ -1202,25 +2024,11 @@ function EnvironmentSection({ projectPath }: { projectPath: string | null }) { projectPath: null, }, ); - if (globalStatus.installed) { - setSkillsStatus(globalStatus); - return; - } - if (projectPath) { - const projectStatus = await invoke( - "check_skills_installed", - { - projectPath, - }, - ); - setSkillsStatus(projectStatus); - } else { - setSkillsStatus(globalStatus); - } + setSkillsStatus(globalStatus); } catch { // Ignore errors silently } - }, [projectPath]); + }, []); useEffect(() => { checkSkillsStatus(); diff --git a/apps/desktop/src/components/workspace/workspace-layout.tsx b/apps/desktop/src/components/workspace/workspace-layout.tsx index 312db72..0cc4b30 100644 --- a/apps/desktop/src/components/workspace/workspace-layout.tsx +++ b/apps/desktop/src/components/workspace/workspace-layout.tsx @@ -1,28 +1,175 @@ -import { useEffect } from "react"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import { PanelRightCloseIcon, PanelRightOpenIcon } from "lucide-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + Panel, + PanelGroup, + PanelResizeHandle, + type ImperativePanelHandle, +} from "react-resizable-panels"; import { Sidebar } from "./sidebar"; import { LatexEditor } from "./editor/latex-editor"; import { PdfPreview } from "./preview/pdf-preview"; import { useDocumentStore } from "@/stores/document-store"; import { usePreviewStore } from "@/stores/preview-store"; +const SIDEBAR_DEFAULT_SIZE = 15; +const SIDEBAR_MIN_SIZE = 10; +const SIDEBAR_COLLAPSED_WIDTH_PX = 48; +const SIDEBAR_COLLAPSED_SIZE_FALLBACK = 8; +const SIDEBAR_ANIMATION_MS = 280; + +function easeInOutSmooth(progress: number) { + return progress * progress * (3 - 2 * progress); +} + export function WorkspaceLayout() { const initialized = useDocumentStore((s) => s.initialized); const previewVisible = usePreviewStore((s) => s.visible); - const togglePreview = usePreviewStore((s) => s.toggle); + const setPreviewVisible = usePreviewStore((s) => s.setVisible); + const workspaceRef = useRef(null); + const sidebarPanelRef = useRef(null); + const sidebarAnimationFrameRef = useRef(null); + const sidebarAnimatingRef = useRef(false); + const expandedSidebarSizeRef = useRef(SIDEBAR_DEFAULT_SIZE); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarCollapsedSize, setSidebarCollapsedSize] = useState( + SIDEBAR_COLLAPSED_SIZE_FALLBACK, + ); + const [codeVisible, setCodeVisible] = useState(true); + + const getCollapsedSidebarSize = useCallback(() => { + const workspaceWidth = + workspaceRef.current?.clientWidth ?? window.innerWidth; + if (!workspaceWidth) return SIDEBAR_COLLAPSED_SIZE_FALLBACK; + return Math.min( + 18, + Math.max(2.5, (SIDEBAR_COLLAPSED_WIDTH_PX / workspaceWidth) * 100), + ); + }, []); + + const animateSidebarToSize = useCallback((targetSize: number) => { + const sidebarPanel = sidebarPanelRef.current; + if (!sidebarPanel) return; + + if (sidebarAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(sidebarAnimationFrameRef.current); + } + + const startSize = sidebarPanel.getSize(); + const sizeDelta = targetSize - startSize; + const startedAt = performance.now(); + sidebarAnimatingRef.current = true; + + const step = (now: number) => { + const progress = Math.min((now - startedAt) / SIDEBAR_ANIMATION_MS, 1); + const nextSize = startSize + sizeDelta * easeInOutSmooth(progress); + + sidebarPanel.resize(nextSize); + + if (progress < 1) { + sidebarAnimationFrameRef.current = window.requestAnimationFrame(step); + return; + } + + sidebarPanel.resize(targetSize); + sidebarAnimationFrameRef.current = null; + sidebarAnimatingRef.current = false; + }; + + sidebarAnimationFrameRef.current = window.requestAnimationFrame(step); + }, []); + + const setSidebarPaneCollapsed = useCallback( + (nextCollapsed: boolean) => { + const sidebarPanel = sidebarPanelRef.current; + if (!sidebarPanel) return; + + if (!nextCollapsed) { + setSidebarCollapsed(false); + animateSidebarToSize(expandedSidebarSizeRef.current); + } else { + const collapsedSize = getCollapsedSidebarSize(); + const currentSize = sidebarPanel.getSize(); + if (currentSize >= SIDEBAR_MIN_SIZE) { + expandedSidebarSizeRef.current = currentSize; + } + setSidebarCollapsedSize(collapsedSize); + setSidebarCollapsed(true); + animateSidebarToSize(collapsedSize); + } + }, + [animateSidebarToSize, getCollapsedSidebarSize], + ); + + const toggleSidebarCollapsed = useCallback(() => { + setSidebarPaneCollapsed(!sidebarCollapsed); + }, [setSidebarPaneCollapsed, sidebarCollapsed]); + + const setCodePaneVisible = useCallback( + (visible: boolean) => { + if (!visible && !previewVisible) { + setPreviewVisible(true); + } + setCodeVisible(visible); + }, + [previewVisible, setPreviewVisible], + ); + + const setPdfPaneVisible = useCallback( + (visible: boolean) => { + if (!visible && !codeVisible) { + setCodeVisible(true); + } + setPreviewVisible(visible); + }, + [codeVisible, setPreviewVisible], + ); // Cmd+\ / Ctrl+\ toggles the PDF preview pane. useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "\\") { e.preventDefault(); - togglePreview(); + setPdfPaneVisible(!previewVisible); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [togglePreview]); + }, [previewVisible, setPdfPaneVisible]); + + useEffect(() => { + return () => { + if (sidebarAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(sidebarAnimationFrameRef.current); + } + }; + }, []); + + useLayoutEffect(() => { + const updateCollapsedSize = () => { + const nextSize = getCollapsedSidebarSize(); + setSidebarCollapsedSize(nextSize); + + if (sidebarCollapsed && !sidebarAnimatingRef.current) { + sidebarPanelRef.current?.resize(nextSize); + } + }; + + updateCollapsedSize(); + + const workspaceElement = workspaceRef.current; + if (!workspaceElement) return; + + const resizeObserver = new ResizeObserver(updateCollapsedSize); + resizeObserver.observe(workspaceElement); + + return () => resizeObserver.disconnect(); + }, [getCollapsedSidebarSize, sidebarCollapsed]); if (!initialized) { return ( @@ -33,44 +180,64 @@ export function WorkspaceLayout() { } return ( - - - - - - - - -
- - -
-
+ + + )} - {previewVisible && ( - <> + {codeVisible && previewVisible && ( + )} - + {previewVisible && ( + - - )} -
+ )} + +
); } diff --git a/apps/desktop/src/hooks/use-claude-events.ts b/apps/desktop/src/hooks/use-claude-events.ts index a5574d1..eb099a6 100644 --- a/apps/desktop/src/hooks/use-claude-events.ts +++ b/apps/desktop/src/hooks/use-claude-events.ts @@ -1,7 +1,9 @@ import { useEffect, useRef } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; +import { remove } from "@tauri-apps/plugin-fs"; import { + CLAUDE_CODE_PROVIDER_ID, useClaudeChatStore, type ClaudeStreamMessage, } from "@/stores/claude-chat-store"; @@ -19,6 +21,21 @@ import { createLogger } from "@/lib/debug/logger"; const log = createLogger("claude-event"); +async function cleanupTemporaryFiles(paths: string[]) { + await Promise.all( + paths.map(async (path) => { + try { + await remove(path); + } catch (err) { + log.warn("failed to remove temporary chat file", { + path, + error: String(err), + }); + } + }), + ); +} + /** Backend event payload shapes (include tab_id for routing) */ interface ClaudeOutputPayload { tab_id: string; @@ -50,6 +67,8 @@ export function useClaudeEvents() { ); const hasTexChangesRef = useRef(new Map()); const cancelledForAskRef = useRef(new Map()); + const lastErrorRef = useRef(new Map()); + const directProviderTabRef = useRef(new Map()); const listenersRef = useRef([]); const msgCountRef = useRef(new Map()); const streamStartTimeRef = useRef(new Map()); @@ -64,6 +83,12 @@ export function useClaudeEvents() { pendingToolUsesRef.current.set(tab.id, new Map()); hasTexChangesRef.current.set(tab.id, false); cancelledForAskRef.current.set(tab.id, false); + lastErrorRef.current.delete(tab.id); + const providerKey = tab.sessionProviderKey ?? tab.providerKey; + directProviderTabRef.current.set( + tab.id, + !!providerKey && providerKey !== CLAUDE_CODE_PROVIDER_ID, + ); msgCountRef.current.set(tab.id, 0); streamStartTimeRef.current.delete(tab.id); lastMsgTimeRef.current.delete(tab.id); @@ -78,6 +103,36 @@ export function useClaudeEvents() { // ── One-time listener setup (mount only) ── useEffect(() => { + function setUserVisibleError(tabId: string, message: string) { + lastErrorRef.current.set(tabId, message); + useClaudeChatStore.getState()._setError(tabId, message); + } + + function providerErrorMessage(payload: string): string | null { + const trimmed = payload.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + const looksProviderRelated = + lower.includes("provider") || + lower.includes("openai") || + lower.includes("api key") || + lower.includes("unauthorized") || + lower.includes("401") || + lower.includes("403") || + lower.includes("404") || + lower.includes("429") || + lower.includes("too many requests") || + lower.includes("rate limit") || + lower.includes("invalid model") || + lower.includes("model access") || + lower.includes("tool_calls") || + lower.includes("unsupported parameter") || + lower.includes("does not support") || + lower.includes("base url"); + if (!looksProviderRelated) return null; + return trimmed.length > 800 ? `${trimmed.slice(0, 800)}...` : trimmed; + } + async function registerProposedChange( filePath: string, toolUseId: string, @@ -193,6 +248,14 @@ export function useClaudeEvents() { log.info( `[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`, ); + if ( + msg.is_error && + msg.subtype !== "cancelled" && + typeof msg.result === "string" && + msg.result.trim() + ) { + setUserVisibleError(tabId, msg.result.trim()); + } } // Extract session_id from system:init @@ -267,15 +330,17 @@ export function useClaudeEvents() { chatStore._appendMessage(tabId, msg); - // When AskUserQuestion is detected, cancel the process so the user + // When a UI-pause tool is detected, cancel the process so the user // can interact with the widget before Claude continues. if (msg.type === "assistant" && msg.message?.content) { - const hasAskUser = msg.message.content.some( - (b: any) => b.type === "tool_use" && b.name === "AskUserQuestion", + const hasUiPauseTool = msg.message.content.some( + (b: any) => + b.type === "tool_use" && + (b.name === "AskUserQuestion" || b.name === "ExitPlanMode"), ); - if (hasAskUser) { + if (hasUiPauseTool) { log.info( - `[${tabId}] ${elapsed(tabId)} AskUserQuestion detected — cancelling process for user input`, + `[${tabId}] ${elapsed(tabId)} UI-pause tool detected - cancelling process for user input`, ); cancelledForAskRef.current.set(tabId, true); invoke("cancel_claude_execution", { tabId }).catch(() => {}); @@ -304,21 +369,27 @@ export function useClaudeEvents() { if ( !success && !tab.error && + !lastErrorRef.current.get(tabId) && !cancelledForAskRef.current.get(tabId) && !chatStore._cancelledByUser ) { + const isDirectProvider = directProviderTabRef.current.get(tabId); if (count === 0) { const isWindows = navigator.userAgent.includes("Windows"); chatStore._setError( tabId, - isWindows - ? "Claude process failed to start. Check that Claude Code CLI is installed and git-bash is available." - : "Claude process failed to start. Check that Claude Code CLI is installed.", + isDirectProvider + ? "AI provider request failed to start. Check the provider API key, Base URL, model name, and model access." + : isWindows + ? "Claude process failed to start. Check that Claude Code CLI is installed and git-bash is available." + : "Claude process failed to start. Check that Claude Code CLI is installed.", ); } else { chatStore._setError( tabId, - "Claude process exited unexpectedly. This may be due to rate limiting or an API error.", + isDirectProvider + ? "AI provider request stopped unexpectedly. Check the provider API key, model access, Base URL, tool-call support, or rate limits." + : "Claude process exited unexpectedly. This may be due to rate limiting or an API error.", ); } } @@ -327,11 +398,55 @@ export function useClaudeEvents() { pendingToolUsesRef.current.delete(tabId); hasTexChangesRef.current.delete(tabId); cancelledForAskRef.current.delete(tabId); + lastErrorRef.current.delete(tabId); + directProviderTabRef.current.delete(tabId); + const completedSessionId = tab.sessionId; chatStore._setStreaming(tabId, false); + void cleanupTemporaryFiles(chatStore.consumeTemporaryFilePaths(tabId)); + + const forceQueuedGuidance = tab.forceQueuedGuidanceOnComplete === true; + if (forceQueuedGuidance) { + const queuedGuidance = useClaudeChatStore + .getState() + .consumeQueuedGuidance(tabId, tab.forcedQueuedGuidanceId); + if (queuedGuidance) { + log.info(`[${tabId}] interrupting current run with queued guidance`); + void useClaudeChatStore + .getState() + .sendPrompt(queuedGuidance.prompt, queuedGuidance.contextOverride, { + tabId, + preserveTabProvider: true, + }); + return; + } + } // Snapshot after Claude edit const projectPath = useDocumentStore.getState().projectRoot; + if (projectPath && completedSessionId) { + void (async () => { + try { + const title = await invoke( + "generate_claude_session_title", + { + projectPath, + sessionId: completedSessionId, + }, + ); + if (title) { + useClaudeChatStore + .getState() + ._setSessionTitle(completedSessionId, title); + } + } catch (err) { + log.warn("failed to refresh completed session title", { + error: String(err), + }); + } + })(); + } + if (projectPath) { try { await useHistoryStore @@ -345,6 +460,20 @@ export function useClaudeEvents() { const docStore = useDocumentStore.getState(); await docStore.refreshFiles(); + const queuedGuidance = success + ? useClaudeChatStore.getState().consumeQueuedGuidance(tabId) + : null; + if (queuedGuidance) { + log.info(`[${tabId}] continuing with queued guidance`); + void useClaudeChatStore + .getState() + .sendPrompt(queuedGuidance.prompt, queuedGuidance.contextOverride, { + tabId, + preserveTabProvider: true, + }); + return; + } + // Auto-recompile after Claude finishes const { projectRoot, @@ -426,6 +555,14 @@ export function useClaudeEvents() { ) { log.error(`[${tabId}] CRITICAL: ${payload}`); } + const isDirectProvider = directProviderTabRef.current.get(tabId); + const providerMessage = + isDirectProvider || providerErrorMessage(payload) + ? providerErrorMessage(payload) || payload.trim() + : null; + if (providerMessage) { + setUserVisibleError(tabId, providerMessage); + } // Surface critical stderr messages to the user UI (only if no error is already set) if ( (payload.includes("git-bash") || diff --git a/apps/desktop/src/hooks/use-keyboard-shortcuts.ts b/apps/desktop/src/hooks/use-keyboard-shortcuts.ts index d6820f6..9a823a6 100644 --- a/apps/desktop/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/desktop/src/hooks/use-keyboard-shortcuts.ts @@ -1,12 +1,6 @@ import { useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { - getAppZoomAction, - resetAppZoom, - shouldHandleAppZoomShortcut, - zoomInApp, - zoomOutApp, -} from "@/lib/app-zoom"; +import { getAppZoomAction, shouldHandleAppZoomShortcut } from "@/lib/app-zoom"; import { useDocumentStore } from "@/stores/document-store"; export function useKeyboardShortcuts() { @@ -18,14 +12,7 @@ export function useKeyboardShortcuts() { } e.preventDefault(); - - if (zoomAction === "in") { - zoomInApp().catch(console.error); - } else if (zoomAction === "out") { - zoomOutApp().catch(console.error); - } else { - resetAppZoom().catch(console.error); - } + e.stopPropagation(); }; const handleKeyDown = (e: KeyboardEvent) => { diff --git a/apps/desktop/src/lib/app-zoom.ts b/apps/desktop/src/lib/app-zoom.ts index 025c04f..ce1e262 100644 --- a/apps/desktop/src/lib/app-zoom.ts +++ b/apps/desktop/src/lib/app-zoom.ts @@ -7,6 +7,8 @@ export const MIN_APP_ZOOM = 0.5; export const MAX_APP_ZOOM = 3; export const APP_ZOOM_STEP = 0.1; +let nativeWheelZoomGuardInstalled = false; + export type AppZoomAction = "in" | "out" | "reset"; type ZoomShortcutEvent = Pick< @@ -45,7 +47,9 @@ export async function persistAppZoom(value: number): Promise { } export function initializeAppZoom(): Promise { - return applyAppZoom(readStoredAppZoom()); + installNativeWheelZoomGuard(); + window.localStorage.removeItem(APP_ZOOM_STORAGE_KEY); + return applyAppZoom(DEFAULT_APP_ZOOM); } export function zoomInApp(): Promise { @@ -102,3 +106,44 @@ export function shouldHandleAppZoomShortcut( if (!(target instanceof Element)) return true; return !target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`); } + +function hasLocalZoomSurfaceAtPoint(event: WheelEvent): boolean { + if (typeof document.elementsFromPoint !== "function") return false; + + return document + .elementsFromPoint(event.clientX, event.clientY) + .some((element) => element.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`)); +} + +function hasLocalZoomSurfaceInPath(event: WheelEvent): boolean { + return event + .composedPath() + .some( + (target) => + target instanceof Element && + !!target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`), + ); +} + +function shouldHandleNativeWheelZoom(event: WheelEvent): boolean { + return ( + shouldHandleAppZoomShortcut(event.target) && + !hasLocalZoomSurfaceInPath(event) && + !hasLocalZoomSurfaceAtPoint(event) + ); +} + +export function installNativeWheelZoomGuard(): void { + if (nativeWheelZoomGuardInstalled || typeof document === "undefined") return; + nativeWheelZoomGuardInstalled = true; + + document.addEventListener( + "wheel", + (event) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey) return; + if (!shouldHandleNativeWheelZoom(event)) return; + event.preventDefault(); + }, + { passive: false }, + ); +} diff --git a/apps/desktop/src/lib/model-capabilities.ts b/apps/desktop/src/lib/model-capabilities.ts new file mode 100644 index 0000000..5e94b90 --- /dev/null +++ b/apps/desktop/src/lib/model-capabilities.ts @@ -0,0 +1,379 @@ +export interface OpenAiCompatibleModelInfo { + id: string; + metadata?: unknown; +} + +interface ModelCapabilityInput { + label?: string | null; + baseUrl?: string | null; + model?: string | null; + metadata?: unknown; +} + +export interface ModelCapabilities { + chat: boolean; + vision: boolean; +} + +const modelMetadataCache = new Map(); + +function normalizeText(value?: unknown) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function metadataKey(baseUrl?: string | null, model?: string | null) { + return `${normalizeText(baseUrl)}::${normalizeText(model)}`; +} + +export function rememberModelCapabilityMetadata( + baseUrl: string | null | undefined, + model: string | null | undefined, + metadata: unknown, +) { + if (!model?.trim()) return; + modelMetadataCache.set(metadataKey(baseUrl, model), metadata); +} + +export function rememberModelListCapabilityMetadata( + baseUrl: string | null | undefined, + models: Array, +) { + for (const model of models) { + if (typeof model === "string") { + rememberModelCapabilityMetadata(baseUrl, model, { id: model }); + } else { + rememberModelCapabilityMetadata( + baseUrl, + model.id, + model.metadata ?? model, + ); + } + } +} + +export function modelInfoId(model: unknown) { + if (typeof model === "string") return model; + if (isPlainObject(model) && typeof model.id === "string") return model.id; + return String(model ?? ""); +} + +function haystack(input: ModelCapabilityInput) { + return [input.label, input.baseUrl, input.model] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +function canonicalModelId(value?: string | null) { + return normalizeText(value) + .replace(/[._/]+/g, "-") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function normalizeToken(value: unknown) { + return String(value) + .trim() + .toLowerCase() + .replace(/[_\s]+/g, "-"); +} + +function booleanValue(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + const normalized = normalizeToken(value); + if (["1", "true", "yes", "supported", "enabled"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "unsupported", "disabled"].includes(normalized)) { + return false; + } + return null; +} + +function valueTokens(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap(valueTokens); + } + if (isPlainObject(value)) { + return Object.entries(value).flatMap(([key, child]) => [ + normalizeToken(key), + ...valueTokens(child), + ]); + } + if (typeof value === "string" || typeof value === "number") { + return normalizeToken(value) + .split(/[^a-z0-9]+/) + .filter(Boolean); + } + return []; +} + +const metadataVisionBooleanKeys = new Set([ + "supports-vision", + "support-vision", + "vision", + "supports-image-input", + "support-image-input", + "image-input", + "images-input", + "supports-images", + "multimodal", + "multi-modal", +]); + +const metadataFeatureKeys = new Set([ + "features", + "capabilities", + "modalities", + "input-modalities", + "supported-modalities", + "supported-input-modalities", + "model-features", + "model-capabilities", +]); + +const visionFeatureTokens = new Set([ + "vision", + "visual", + "image", + "images", + "image-input", + "input-image", + "multimodal", + "multi-modal", +]); + +const chatModeTokens = new Set([ + "chat", + "completion", + "completions", + "messages", + "text-generation", + "text", + "llm", +]); + +const nonChatModeTokens = new Set([ + "embedding", + "embeddings", + "rerank", + "reranking", + "moderation", + "whisper", + "tts", + "text-to-speech", + "speech-to-text", + "speech", + "audio", + "asr", + "stt", + "image-generation", + "image-to-video", + "video-generation", + "text-embedding", + "realtime", +]); + +function metadataVisionCapability( + value: unknown, + depth = 0, +): boolean | undefined { + if (depth > 5) return undefined; + if (!isPlainObject(value)) return undefined; + + for (const [rawKey, child] of Object.entries(value)) { + const key = normalizeToken(rawKey); + if (metadataVisionBooleanKeys.has(key)) { + const parsed = booleanValue(child); + if (parsed !== null) return parsed; + } + + if (metadataFeatureKeys.has(key)) { + const tokens = valueTokens(child); + if (tokens.some((token) => visionFeatureTokens.has(token))) return true; + } + + const nested = metadataVisionCapability(child, depth + 1); + if (nested !== undefined) return nested; + } + + return undefined; +} + +function metadataChatCapability( + value: unknown, + depth = 0, +): boolean | undefined { + if (depth > 5) return undefined; + if (!isPlainObject(value)) return undefined; + + for (const [rawKey, child] of Object.entries(value)) { + const key = normalizeToken(rawKey); + if ( + [ + "mode", + "type", + "task", + "model-type", + "endpoint-type", + "category", + ].includes(key) + ) { + const tokens = valueTokens(child); + if (tokens.some((token) => nonChatModeTokens.has(token))) return false; + if (tokens.some((token) => chatModeTokens.has(token))) return true; + } + + if (metadataFeatureKeys.has(key)) { + const tokens = valueTokens(child); + if (tokens.some((token) => chatModeTokens.has(token))) return true; + } + + const nested = metadataChatCapability(child, depth + 1); + if (nested !== undefined) return nested; + } + + return undefined; +} + +function isNonChatModel(value: string) { + return Array.from(nonChatModeTokens).some((marker) => value.includes(marker)); +} + +function isQwenProvider(value: string) { + return [ + "qwen", + "dashscope", + "aliyuncs.com", + "alibabacloud", + "alibaba-cloud", + "modelstudio", + "bailian", + ].some((marker) => value.includes(marker)); +} + +function isQwenVisionModel(model: string) { + const id = canonicalModelId(model); + + if ( + [ + "qwen-vl", + "qwen2-vl", + "qwen2-5-vl", + "qwen3-vl", + "qwen-omni", + "qwen2-5-omni", + "qwen3-omni", + "qvq", + ].some((prefix) => id === prefix || id.startsWith(`${prefix}-`)) + ) { + return true; + } + + if (!id.startsWith("qwen")) return false; + if ( + ["audio", "coder", "code", "embedding", "math", "rerank", "tts"].some( + (token) => id.includes(token), + ) + ) { + return false; + } + + const version = id.match(/^qwen-?(\d+)-(\d+)/); + if (!version) return false; + + const major = Number(version[1]); + const minor = Number(version[2]); + return major > 3 || (major === 3 && minor >= 5); +} + +function curatedVisionCapability(input: ModelCapabilityInput, value: string) { + const model = input.model ?? ""; + + if (isQwenProvider(value) && isQwenVisionModel(model)) { + return true; + } + + return undefined; +} + +function hasExplicitVisionFamily(value: string) { + const normalized = value.toLowerCase(); + const tokens = normalized.split(/[^a-z0-9]+/).filter(Boolean); + + if (tokens.includes("vl")) return true; + + return [ + "vision", + "multimodal", + "multi-modal", + "omni", + "qvq", + "llava", + "bakllava", + "moondream", + "minicpm-v", + "glm-4v", + "glm-4.5v", + "internvl", + "pixtral", + "llama3.2-vision", + "granite3.2-vision", + "gpt-4o", + "gpt-4.1", + "gpt-4.5", + "gpt-4-turbo", + "gpt-4-vision", + "o3", + "o4", + "gemini", + ].some((marker) => normalized.includes(marker)); +} + +function modelMetadata(input: ModelCapabilityInput) { + return ( + input.metadata ?? + modelMetadataCache.get(metadataKey(input.baseUrl, input.model)) ?? + modelMetadataCache.get(metadataKey(null, input.model)) + ); +} + +export function getModelCapabilities( + input: ModelCapabilityInput, +): ModelCapabilities { + const value = haystack(input); + const model = normalizeText(input.model); + + if (!model) { + return { chat: true, vision: false }; + } + + const metadata = modelMetadata(input); + const metadataChat = metadataChatCapability(metadata); + const metadataVision = metadataVisionCapability(metadata); + + const chat = metadataChat ?? !isNonChatModel(value); + if (!chat) { + return { chat: false, vision: false }; + } + + return { + chat, + vision: + metadataVision ?? + curatedVisionCapability(input, value) ?? + hasExplicitVisionFamily(value), + }; +} + +export function isChatModelOption(input: ModelCapabilityInput) { + return getModelCapabilities(input).chat; +} diff --git a/apps/desktop/src/lib/mupdf/pdf-doc-cache.ts b/apps/desktop/src/lib/mupdf/pdf-doc-cache.ts index 7ad3276..56f26f9 100644 --- a/apps/desktop/src/lib/mupdf/pdf-doc-cache.ts +++ b/apps/desktop/src/lib/mupdf/pdf-doc-cache.ts @@ -123,12 +123,18 @@ export function invalidateDoc(docId: number): void { /** Close all cached documents (e.g., on project close). */ export async function clearDocCache(): Promise { - const count = cache.size; + const entries = [...cache.values()]; + const count = entries.length; + cache.clear(); + if (count === 0) { + log.info("Cleared doc cache (0 documents closed)"); + return; + } + const client = getMupdfClient(); - const closePromises = [...cache.values()].map((entry) => + const closePromises = entries.map((entry) => client.closeDocument(entry.docId).catch(() => {}), ); - cache.clear(); await Promise.all(closePromises); log.info(`Cleared doc cache (${count} documents closed)`); } diff --git a/apps/desktop/src/lib/project-attachments.ts b/apps/desktop/src/lib/project-attachments.ts new file mode 100644 index 0000000..2c0e650 --- /dev/null +++ b/apps/desktop/src/lib/project-attachments.ts @@ -0,0 +1,56 @@ +import { copyFileToProject } from "@/lib/tauri/fs"; + +export interface ImportedReferenceFile { + relativePath: string; +} + +function baseName(path: string): string { + return path.split(/[/\\]/).pop() || path; +} + +function isPdfPath(path: string): boolean { + return path.toLowerCase().endsWith(".pdf"); +} + +export async function importReferenceFiles( + projectRoot: string, + sourcePaths: string[], + targetFolder = "attachments", +): Promise { + const imported: ImportedReferenceFile[] = []; + + for (const sourcePath of sourcePaths) { + const targetName = `${targetFolder}/${baseName(sourcePath)}`; + const relativePath = await copyFileToProject( + projectRoot, + sourcePath, + targetName, + ); + const reference: ImportedReferenceFile = { relativePath }; + imported.push(reference); + } + + return imported; +} + +export function buildReferenceFilesSection( + references: ImportedReferenceFile[], +): string { + if (references.length === 0) return ""; + + const lines = references.map((reference) => { + if (isPdfPath(reference.relativePath)) { + return `- \`${reference.relativePath}\` (PDF)`; + } + return `- \`${reference.relativePath}\``; + }); + + return [ + "", + "### Reference Files", + lines.join("\n"), + "", + "Please review them and incorporate relevant information.", + "", + ].join("\n"); +} diff --git a/apps/desktop/src/lib/project-name.ts b/apps/desktop/src/lib/project-name.ts new file mode 100644 index 0000000..c98b128 --- /dev/null +++ b/apps/desktop/src/lib/project-name.ts @@ -0,0 +1,22 @@ +export function normalizeProjectName(name: string): string { + return name.trim(); +} + +export function getProjectNameError(name: string): string | null { + const trimmed = normalizeProjectName(name); + if (!trimmed) return "Enter a project name"; + if (trimmed === "." || trimmed === "..") { + return "Project name cannot be . or .."; + } + const hasControlCharacter = Array.from(trimmed).some( + (char) => char.charCodeAt(0) < 32, + ); + if ( + hasControlCharacter || + /[\\/<>:"|?*]/.test(trimmed) || + /[\s.]$/.test(trimmed) + ) { + return "Project name contains characters Windows cannot use"; + } + return null; +} diff --git a/apps/desktop/src/lib/provider-icons.ts b/apps/desktop/src/lib/provider-icons.ts new file mode 100644 index 0000000..1631b72 --- /dev/null +++ b/apps/desktop/src/lib/provider-icons.ts @@ -0,0 +1,161 @@ +import anthropicIcon from "@/assets/providers/anthropic.svg"; +import deepseekIcon from "@/assets/providers/deepseek.svg"; +import geminiIcon from "@/assets/providers/gemini-color.svg"; +import moonshotIcon from "@/assets/providers/moonshot.svg"; +import ollamaIcon from "@/assets/providers/ollama.svg"; +import openaiIcon from "@/assets/providers/openai.svg"; +import qwenIcon from "@/assets/providers/qwen.svg"; +import zhipuIcon from "@/assets/providers/zhipu-color.svg"; + +interface ProviderIconInput { + label?: string | null; + baseUrl?: string | null; + model?: string | null; + id?: string | null; +} + +function providerHaystack(input: ProviderIconInput) { + return [input.id, input.label, input.baseUrl, input.model] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +function isGenericOpenAiLabel(label?: string | null) { + const normalized = label?.trim().toLowerCase(); + return ( + !normalized || + normalized === "custom openai api" || + normalized === "openai-compatible provider" + ); +} + +export function getProviderDisplayName(input: ProviderIconInput): string { + const haystack = providerHaystack(input); + + if ( + haystack.includes("ollama") || + haystack.includes("localhost:11434") || + haystack.includes("127.0.0.1:11434") + ) { + return "Ollama"; + } + + if ( + haystack.includes("qwen") || + haystack.includes("dashscope") || + haystack.includes("aliyuncs") + ) { + return "Qwen"; + } + + if (haystack.includes("deepseek")) { + return "DeepSeek"; + } + + if ( + haystack.includes("glm") || + haystack.includes("zhipu") || + haystack.includes("bigmodel") || + haystack.includes("open.bigmodel.cn") + ) { + return "GLM"; + } + + if ( + haystack.includes("gemini") || + haystack.includes("googleapis") || + haystack.includes("generativelanguage") + ) { + return "Gemini"; + } + + if ( + haystack.includes("moonshot") || + haystack.includes("kimi") || + haystack.includes("api.moonshot.cn") + ) { + return "Moonshot / Kimi"; + } + + if ( + haystack.includes("anthropic") || + haystack.includes("claude") || + haystack.includes("sk-ant") + ) { + return "Anthropic"; + } + + if (haystack.includes("openai") || haystack.includes("api.openai.com")) { + return "OpenAI"; + } + + if (!isGenericOpenAiLabel(input.label)) { + return input.label!.trim(); + } + + return "Provider"; +} + +export function getProviderIconSrc(input: ProviderIconInput): string | null { + const haystack = providerHaystack(input); + + if ( + haystack.includes("ollama") || + haystack.includes("localhost:11434") || + haystack.includes("127.0.0.1:11434") + ) { + return ollamaIcon; + } + + if ( + haystack.includes("qwen") || + haystack.includes("dashscope") || + haystack.includes("aliyuncs") + ) { + return qwenIcon; + } + + if (haystack.includes("deepseek")) { + return deepseekIcon; + } + + if ( + haystack.includes("glm") || + haystack.includes("zhipu") || + haystack.includes("bigmodel") || + haystack.includes("open.bigmodel.cn") + ) { + return zhipuIcon; + } + + if ( + haystack.includes("gemini") || + haystack.includes("googleapis") || + haystack.includes("generativelanguage") + ) { + return geminiIcon; + } + + if ( + haystack.includes("moonshot") || + haystack.includes("kimi") || + haystack.includes("api.moonshot.cn") + ) { + return moonshotIcon; + } + + if ( + haystack.includes("anthropic") || + haystack.includes("claude") || + haystack.includes("sk-ant") + ) { + return anthropicIcon; + } + + if (haystack.includes("openai") || haystack.includes("api.openai.com")) { + return openaiIcon; + } + + return null; +} diff --git a/apps/desktop/src/lib/tauri/fs.ts b/apps/desktop/src/lib/tauri/fs.ts index 4a399d2..e9acfbe 100644 --- a/apps/desktop/src/lib/tauri/fs.ts +++ b/apps/desktop/src/lib/tauri/fs.ts @@ -63,7 +63,7 @@ const IGNORED_DIRECTORY_NAMES = new Set([ ]); const IGNORED_EXTENSIONS = new Set([ - // Ignored file extensions: LaTeX build artifacts and other non-editable/binary files + // Ignore LaTeX build artifacts, but keep user-imported reference files visible. ".aux", ".log", ".out", @@ -81,50 +81,6 @@ const IGNORED_EXTENSIONS = new Set([ ".vrb", ".run.xml", ".bcf", - // Binary / non-text files (cannot be meaningfully edited) - ".hwp", - ".hwpx", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".xlsm", - ".ppt", - ".pptx", - ".accdb", - ".mdb", - ".zip", - ".rar", - ".7z", - ".tar", - ".gz", - ".exe", - ".dll", - ".pyd", - ".so", - ".dylib", - ".o", - ".obj", - ".bin", - ".pyc", - ".pyo", - ".dat", - ".iso", - ".dmg", - ".msi", - ".mp3", - ".mp4", - ".avi", - ".mov", - ".mkv", - ".wav", - ".flac", - ".psd", - ".ai", - ".sketch", - ".fig", - ".sqlite", - ".db", ]); export function shouldSkipProjectDirectory(name: string): boolean { diff --git a/apps/desktop/src/stores/claude-chat-store.ts b/apps/desktop/src/stores/claude-chat-store.ts index bdcd818..7b621c5 100644 --- a/apps/desktop/src/stores/claude-chat-store.ts +++ b/apps/desktop/src/stores/claude-chat-store.ts @@ -2,9 +2,42 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; import { useDocumentStore } from "./document-store"; import { useHistoryStore } from "./history-store"; +import { useClaudeSetupStore } from "./claude-setup-store"; import { createLogger } from "@/lib/debug/logger"; const log = createLogger("claude"); +export const CLAUDE_CODE_PROVIDER_ID = "__claude-code__"; +export const SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY = + "claude-prism:selected-provider-credential-id"; + +function providerSelectionStorage(): Storage | null { + try { + return globalThis.sessionStorage ?? null; + } catch { + return null; + } +} + +export function loadSelectedProviderCredentialId(): string | null { + const value = providerSelectionStorage()?.getItem( + SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY, + ); + const trimmed = value?.trim(); + return trimmed || null; +} + +function persistSelectedProviderCredentialId(credentialId: string | null) { + const storage = providerSelectionStorage(); + if (!storage) return; + if (credentialId?.trim()) { + storage.setItem( + SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY, + credentialId.trim(), + ); + } else { + storage.removeItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY); + } +} /** Convert a character offset to 1-based line:col */ export function offsetToLineCol( @@ -64,19 +97,45 @@ export interface TabDraft { filePath: string; selectedText: string; imageDataUrl?: string; + isTemporary?: boolean; }[]; } +export interface PromptContextOverride { + label: string; + filePath: string; + selectedText: string; + temporaryFilePaths?: string[]; +} + +export interface QueuedGuidance { + id: string; + prompt: string; + contextOverride?: PromptContextOverride; + createdAt: number; + displayedInChat?: boolean; +} + export interface TabState { id: string; title: string; + projectPath: string | null; sessionId: string | null; + /** Provider currently selected in the tab UI. */ + providerKey: string | null; + /** Provider that last executed this session, used for safe resume/switching. */ + sessionProviderKey: string | null; messages: ClaudeStreamMessage[]; isStreaming: boolean; + streamingStartedAt: number | null; error: string | null; totalInputTokens: number; totalOutputTokens: number; draft: TabDraft; + queuedGuidance?: QueuedGuidance[]; + forceQueuedGuidanceOnComplete?: boolean; + forcedQueuedGuidanceId?: string | null; + pendingTemporaryFilePaths?: string[]; } /** Fields that are projected from the active tab to top-level state */ @@ -84,30 +143,346 @@ const TAB_FIELDS = [ "sessionId", "messages", "isStreaming", + "streamingStartedAt", "error", "totalInputTokens", "totalOutputTokens", ] as const; -function makeDefaultTab(id: string): TabState { +function makeDefaultTab( + id: string, + projectPath: string | null = null, +): TabState { + const selectedCredentialId = + loadSelectedProviderCredentialId() ?? CLAUDE_CODE_PROVIDER_ID; return { id, title: "New Chat", + projectPath, sessionId: null, + providerKey: providerKeyForSelectedCredential(selectedCredentialId), + sessionProviderKey: null, messages: [], isStreaming: false, + streamingStartedAt: null, error: null, totalInputTokens: 0, totalOutputTokens: 0, draft: { input: "", pinnedContexts: [] }, + queuedGuidance: [], + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, + pendingTemporaryFilePaths: [], }; } +function providerSessionKey(providerCredentialId: string | null): string { + return providerCredentialId + ? `openai-compatible:${providerCredentialId}` + : CLAUDE_CODE_PROVIDER_ID; +} + +function providerKeyForSelectedCredential(credentialId: string | null): string { + return credentialId && credentialId !== CLAUDE_CODE_PROVIDER_ID + ? providerSessionKey(credentialId) + : CLAUDE_CODE_PROVIDER_ID; +} + +function providerCredentialIdFromSessionKey( + providerKey: string | null, +): string | null | undefined { + if (!providerKey) return undefined; + if (providerKey === CLAUDE_CODE_PROVIDER_ID) return CLAUDE_CODE_PROVIDER_ID; + const prefix = "openai-compatible:"; + return providerKey.startsWith(prefix) + ? providerKey.slice(prefix.length) + : undefined; +} + +function selectedCredentialForProviderKey(providerKey: string | null) { + const credentialId = providerCredentialIdFromSessionKey(providerKey); + return credentialId === undefined ? null : credentialId; +} + +function inferProviderKeyFromHistory(history: any[]): string | null { + const init = history.find( + (entry) => entry?.type === "system" && entry?.subtype === "init", + ); + if (!init) return null; + + if ( + init.provider === "openai-compatible" && + typeof init.provider_credential_id === "string" && + init.provider_credential_id.trim() + ) { + return providerSessionKey(init.provider_credential_id.trim()); + } + + const model = typeof init.model === "string" ? init.model : ""; + if (model.toLowerCase().startsWith("claude")) { + return CLAUDE_CODE_PROVIDER_ID; + } + + const matchingCredential = useClaudeSetupStore + .getState() + .openAiCredentials.find((credential) => credential.model === model); + return matchingCredential ? providerSessionKey(matchingCredential.id) : null; +} + +function usageFromMessage(msg: ClaudeStreamMessage): { + input_tokens: number; + output_tokens: number; +} { + const usage = msg.usage || msg.message?.usage; + return { + input_tokens: usage?.input_tokens || 0, + output_tokens: usage?.output_tokens || 0, + }; +} + +function usageTotalsForMessages(messages: ClaudeStreamMessage[]): { + inputTokens: number; + outputTokens: number; +} { + return messages.reduce( + (totals, msg) => { + const usage = usageFromMessage(msg); + totals.inputTokens += usage.input_tokens; + totals.outputTokens += usage.output_tokens; + return totals; + }, + { inputTokens: 0, outputTokens: 0 }, + ); +} + +function stringifyBlockContent(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function messageContentText(message: ClaudeStreamMessage): string { + const rawContent = (message.message as any)?.content; + if (typeof rawContent === "string") return rawContent.trim(); + + const blocks = rawContent ?? []; + const parts: string[] = []; + for (const block of blocks) { + if (block.type === "text" && block.text?.trim()) { + parts.push(block.text.trim()); + } else if (block.type === "tool_use") { + const input = block.input ? stringifyBlockContent(block.input) : ""; + parts.push( + `[tool_use: ${block.name ?? "unknown"}${input ? ` ${input}` : ""}]`, + ); + } else if (block.type === "tool_result") { + const content = stringifyBlockContent(block.content ?? ""); + parts.push(`[tool_result${block.is_error ? " error" : ""}: ${content}]`); + } + } + return parts.join("\n").trim(); +} + +function displayTextForStoredUserPrompt(text: string): string { + const normalized = text.replace(/\r\n/g, "\n"); + if (!/^\[(?:Currently open file|File): [^\]\n]*\]/.test(normalized)) { + return text; + } + + const contextEnd = normalized.lastIndexOf("]\n\n"); + if (contextEnd < 0) return text; + + const contextText = normalized.slice(0, contextEnd + 1); + const body = normalized.slice(contextEnd + 3); + const selectionMatch = contextText.match(/(?:^|\n)\[Selection: ([^\]\n]+)\]/); + const contextLabel = selectionMatch?.[1]?.trim(); + + if (!contextLabel) return body; + return body.trim() ? `${contextLabel}\n${body}` : contextLabel; +} + +function sanitizeStoredUserMessageForDisplay( + message: ClaudeStreamMessage, +): ClaudeStreamMessage { + if (message.type !== "user") return message; + + const rawContent = (message.message as any)?.content; + if (typeof rawContent === "string") { + const displayText = displayTextForStoredUserPrompt(rawContent); + return displayText === rawContent + ? message + : { + ...message, + message: { ...message.message, content: displayText as any }, + }; + } + + if (!Array.isArray(rawContent)) return message; + + let changed = false; + const content = rawContent.map((block) => { + if (block.type !== "text" || typeof block.text !== "string") { + return block; + } + + const displayText = displayTextForStoredUserPrompt(block.text); + if (displayText === block.text) return block; + + changed = true; + return { ...block, text: displayText }; + }); + + return changed + ? { ...message, message: { ...message.message, content } } + : message; +} + +function buildProviderSwitchContext( + messages: ClaudeStreamMessage[], + maxChars = 18000, +): string | null { + const entries = messages + .filter((msg) => msg.type === "user" || msg.type === "assistant") + .map((msg) => { + const text = messageContentText(msg); + if (!text) return null; + return `${msg.type === "user" ? "User" : "Assistant"}:\n${text}`; + }) + .filter((entry): entry is string => !!entry); + + if (entries.length === 0) return null; + + const selected: string[] = []; + let total = 0; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const next = entries[i]; + if (selected.length > 0 && total + next.length > maxChars) break; + selected.unshift(next); + total += next.length; + } + + return [ + "[Provider switch context]", + "The conversation below happened earlier in this same ClaudePrism chat before switching model providers.", + "Use it as prior context. Do not repeat it; answer only the user's latest request after this block.", + "", + selected.join("\n\n"), + "[End provider switch context]", + ].join("\n"); +} + let tabCounter = 0; function nextTabId(): string { return `tab-${++tabCounter}`; } +function nextGuidanceId(): string { + return `guidance-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function truncateChatTitle(text: string, maxChars = 80): string { + return text.length > maxChars + ? `${text.slice(0, Math.max(0, maxChars - 3))}...` + : text; +} + +function normalizeChatTitleWhitespace(text: string): string { + return text.trim().replace(/\s+/g, " "); +} + +function isNoiseChatTitleLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return true; + const lower = trimmed.toLowerCase(); + return ( + lower.startsWith("template:") || + lower.startsWith("file:") || + lower.startsWith("reference files") || + lower === "what i want to create" || + lower.startsWith("(extracted text") || + lower.startsWith("attachments/") || + lower.startsWith("the file currently contains") || + (lower.startsWith("new ") && lower.includes(" project")) + ); +} + +function extractMarkedRequestBody(text: string): string | null { + const lines = text.split(/\r?\n/); + const markerIndex = lines.findIndex( + (line) => line.trim().toLowerCase() === "what i want to create", + ); + if (markerIndex < 0) return null; + + const selected: string[] = []; + for (const line of lines.slice(markerIndex + 1)) { + const trimmed = line.trim(); + if (trimmed.toLowerCase() === "reference files") break; + if (isNoiseChatTitleLine(trimmed)) continue; + selected.push(trimmed); + if (selected.join(" ").length >= 120) break; + } + + const body = normalizeChatTitleWhitespace(selected.join(" ")); + return body || null; +} + +function firstMeaningfulTitleLine(text: string): string | null { + for (const line of text.split(/\r?\n/)) { + if (!isNoiseChatTitleLine(line)) { + const normalized = normalizeChatTitleWhitespace(line); + if (normalized) return normalized; + } + } + return null; +} + +function summarizeChatTitle(prompt: string): string | undefined { + const clean = prompt.includes("]\n\n") + ? prompt.slice(prompt.lastIndexOf("]\n\n") + 3) + : prompt; + if ( + clean.startsWith("") || + clean.startsWith("") || + clean.startsWith("") + ) { + return undefined; + } + + const source = + extractMarkedRequestBody(clean) ?? firstMeaningfulTitleLine(clean); + if (!source) return undefined; + + const normalized = normalizeChatTitleWhitespace(source); + const lower = normalized.toLowerCase(); + const researchPrefix = [ + "a research paper for ", + "research paper for ", + "a research paper on ", + "research paper on ", + "a research paper about ", + "research paper about ", + ].find((prefix) => lower.startsWith(prefix)); + + if (researchPrefix) { + const topic = normalized.slice(researchPrefix.length).trim(); + return topic + ? `Research Paper: ${truncateChatTitle(topic, 56)}` + : "Research Paper"; + } + + return truncateChatTitle(normalized); +} + +function titleForMessages(messages: ClaudeStreamMessage[]): string | undefined { + const firstUser = messages.find((message) => message.type === "user"); + if (!firstUser) return undefined; + return summarizeChatTitle(messageContentText(firstUser)); +} + /** * Update a specific tab in `tabs[]` and, if that tab is the active tab, * also project the changed fields to top-level state for consumer compatibility. @@ -133,6 +508,41 @@ function applyTabUpdate( // ─── State Interface ─── +function mergeStreamingContent( + existing: ContentBlock[], + incoming: ContentBlock[], +): ContentBlock[] { + let merged = [...existing]; + for (const block of incoming) { + if (block.type === "text" && block.text) { + const idx = merged.findIndex((item) => item.type === "text"); + if (idx >= 0) { + merged = merged.map((item, itemIdx) => + itemIdx === idx + ? { ...item, text: `${item.text ?? ""}${block.text}` } + : item, + ); + } else { + merged.push(block); + } + } else if (block.type === "thinking" && block.thinking) { + const idx = merged.findIndex((item) => item.type === "thinking"); + if (idx >= 0) { + merged = merged.map((item, itemIdx) => + itemIdx === idx + ? { ...item, thinking: `${item.thinking ?? ""}${block.thinking}` } + : item, + ); + } else { + merged.unshift(block); + } + } else { + merged.push(block); + } + } + return merged; +} + const DEFAULT_TAB_ID = nextTabId(); interface ClaudeChatState { @@ -140,6 +550,7 @@ interface ClaudeChatState { messages: ClaudeStreamMessage[]; sessionId: string | null; isStreaming: boolean; + streamingStartedAt: number | null; error: string | null; totalInputTokens: number; totalOutputTokens: number; @@ -147,6 +558,7 @@ interface ClaudeChatState { // ── Tab state ── tabs: TabState[]; activeTabId: string; + activeProjectPath: string | null; /** Deferred prompt to send once the workspace is ready (set by project wizard) */ pendingInitialPrompt: string | null; @@ -172,10 +584,17 @@ interface ClaudeChatState { selectedText: string; imageDataUrl?: string; }[]; + pendingPinnedContextRemovalLabels: string[]; + requestPinnedContextRemoval: (labels: string[]) => void; + consumePendingPinnedContextRemovals: () => string[]; /** Currently selected model (passed per-prompt to Claude CLI) */ selectedModel: "sonnet" | "opus" | "haiku" | "opusplan"; setSelectedModel: (model: "sonnet" | "opus" | "haiku" | "opusplan") => void; + selectedProviderCredentialId: string | null; + setSelectedProviderCredentialId: (credentialId: string | null) => void; + selectedProviderModels: Record; + setSelectedProviderModel: (credentialId: string, model: string) => void; /** Effort level for Opus 4.6 adaptive reasoning */ effortLevel: "low" | "medium" | "high"; @@ -184,12 +603,31 @@ interface ClaudeChatState { // Actions sendPrompt: ( userPrompt: string, - contextOverride?: { label: string; filePath: string; selectedText: string }, + contextOverride?: PromptContextOverride, + options?: { tabId?: string; preserveTabProvider?: boolean }, ) => Promise; - cancelExecution: () => Promise; + queueGuidance: ( + tabId: string, + prompt: string, + contextOverride?: PromptContextOverride, + ) => void; + consumeQueuedGuidance: ( + tabId: string, + guidanceId?: string | null, + ) => QueuedGuidance | null; + displayQueuedGuidanceInChat: ( + tabId: string, + guidanceId?: string | null, + ) => string | null; + removeQueuedGuidance: (tabId: string, guidanceId: string) => void; + clearQueuedGuidance: (tabId: string) => void; + consumeTemporaryFilePaths: (tabId: string) => string[]; + forceQueuedGuidanceNow: (tabId: string, guidanceId?: string) => Promise; + cancelExecution: (tabId?: string) => Promise; clearMessages: () => void; newSession: () => void; - resumeSession: (sessionId: string) => Promise; + resetForProject: (projectPath: string | null) => void; + resumeSession: (sessionId: string, title?: string) => Promise; // Tab actions createTab: () => string; @@ -203,6 +641,7 @@ interface ClaudeChatState { // Internal actions (called by event hook, routed by tabId) _appendMessage: (tabId: string, msg: ClaudeStreamMessage) => void; _setSessionId: (tabId: string, id: string) => void; + _setSessionTitle: (sessionId: string, title: string) => void; _setStreaming: (tabId: string, streaming: boolean) => void; _setError: (tabId: string, error: string | null) => void; _cancelledByUser: boolean; @@ -215,6 +654,7 @@ export const useClaudeChatStore = create()((set, get) => ({ messages: [], sessionId: null, isStreaming: false, + streamingStartedAt: null, error: null, _cancelledByUser: false, totalInputTokens: 0, @@ -223,9 +663,32 @@ export const useClaudeChatStore = create()((set, get) => ({ // Tab state tabs: [makeDefaultTab(DEFAULT_TAB_ID)], activeTabId: DEFAULT_TAB_ID, + activeProjectPath: null, selectedModel: "opus", setSelectedModel: (model) => set({ selectedModel: model }), + selectedProviderCredentialId: + loadSelectedProviderCredentialId() ?? CLAUDE_CODE_PROVIDER_ID, + setSelectedProviderCredentialId: (credentialId) => { + persistSelectedProviderCredentialId(credentialId); + const providerKey = providerKeyForSelectedCredential( + credentialId ?? CLAUDE_CODE_PROVIDER_ID, + ); + set((state) => ({ + selectedProviderCredentialId: credentialId, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId ? { ...tab, providerKey } : tab, + ), + })); + }, + selectedProviderModels: {}, + setSelectedProviderModel: (credentialId, model) => + set((state) => ({ + selectedProviderModels: { + ...state.selectedProviderModels, + [credentialId]: model, + }, + })), effortLevel: "medium", setEffortLevel: (level) => set({ effortLevel: level }), @@ -253,27 +716,35 @@ export const useClaudeChatStore = create()((set, get) => ({ } return pendingAttachments; }, + pendingPinnedContextRemovalLabels: [], + requestPinnedContextRemoval: (labels) => { + if (labels.length === 0) return; + set((state) => ({ + pendingPinnedContextRemovalLabels: [ + ...state.pendingPinnedContextRemovalLabels, + ...labels, + ], + })); + }, + consumePendingPinnedContextRemovals: () => { + const { pendingPinnedContextRemovalLabels } = get(); + if (pendingPinnedContextRemovalLabels.length > 0) { + set({ pendingPinnedContextRemovalLabels: [] }); + } + return pendingPinnedContextRemovalLabels; + }, anyStreaming: () => get().tabs.some((t) => t.isStreaming), sendPrompt: async ( userPrompt: string, - contextOverride?: { label: string; filePath: string; selectedText: string }, + contextOverride?: PromptContextOverride, + options?: { tabId?: string; preserveTabProvider?: boolean }, ) => { - const state = get(); - const { activeTabId } = state; - const activeTab = state.tabs.find((t) => t.id === activeTabId); - // Guard: prevent sending from a tab that's already streaming - if (activeTab?.isStreaming) return; - - const { sessionId, selectedModel, effortLevel } = state; - - const sendStart = performance.now(); - log.info("sendPrompt start", { - sessionId: !!sessionId, - hasContext: !!contextOverride, - tab: activeTabId, - }); + let state = get(); + let activeTabId = options?.tabId ?? state.activeTabId; + let activeTab = state.tabs.find((t) => t.id === activeTabId); + if (!activeTab || activeTab.isStreaming) return; const docState = useDocumentStore.getState(); const projectPath = docState.projectRoot; @@ -282,6 +753,62 @@ export const useClaudeChatStore = create()((set, get) => ({ return; } + if (activeTab.projectPath && activeTab.projectPath !== projectPath) { + get().resetForProject(projectPath); + state = get(); + activeTabId = state.activeTabId; + activeTab = state.tabs.find((t) => t.id === activeTabId); + if (!activeTab || activeTab.isStreaming) return; + } + + const { selectedModel, effortLevel, selectedProviderModels } = state; + const sessionId = activeTab.sessionId; + const tabSelectedProviderCredentialId = + selectedCredentialForProviderKey(activeTab.providerKey) ?? + state.selectedProviderCredentialId; + let providerCredentialId = + tabSelectedProviderCredentialId && + tabSelectedProviderCredentialId !== CLAUDE_CODE_PROVIDER_ID + ? tabSelectedProviderCredentialId + : null; + + if (options?.preserveTabProvider && activeTab.providerKey) { + const tabProviderCredentialId = providerCredentialIdFromSessionKey( + activeTab.providerKey, + ); + if (tabProviderCredentialId === CLAUDE_CODE_PROVIDER_ID) { + providerCredentialId = null; + } else if (tabProviderCredentialId !== undefined) { + providerCredentialId = tabProviderCredentialId; + } + } + + const providerModelOverride = providerCredentialId + ? selectedProviderModels[providerCredentialId] || null + : null; + const requestProviderKey = providerSessionKey(providerCredentialId); + const previousProviderKey = activeTab?.sessionProviderKey ?? null; + const providerChanged = + !!sessionId && + !!previousProviderKey && + previousProviderKey !== requestProviderKey; + const switchingDirectProviderToClaudeCode = + providerChanged && + requestProviderKey === CLAUDE_CODE_PROVIDER_ID && + previousProviderKey !== CLAUDE_CODE_PROVIDER_ID; + const resumeSessionId = switchingDirectProviderToClaudeCode + ? null + : (sessionId ?? null); + + const sendStart = performance.now(); + const streamingStartedAt = Date.now(); + log.info("sendPrompt start", { + sessionId: !!sessionId, + providerChanged, + hasContext: !!contextOverride, + tab: activeTabId, + }); + // Compute context label for display in chat history const activeFile = docState.files.find( (f) => f.id === docState.activeFileId, @@ -314,21 +841,32 @@ export const useClaudeChatStore = create()((set, get) => ({ // Auto-set tab title from first prompt const isFirstMessage = activeTab && activeTab.messages.length === 0; const tabTitle = isFirstMessage - ? userPrompt.slice(0, 40) + (userPrompt.length > 40 ? "..." : "") + ? summarizeChatTitle(userPrompt) : undefined; set((s) => { + const currentTab = s.tabs.find((t) => t.id === activeTabId); + const temporaryFilePaths = Array.from( + new Set([ + ...(currentTab?.pendingTemporaryFilePaths ?? []), + ...(contextOverride?.temporaryFilePaths ?? []), + ]), + ); const tabUpdates: Partial = { - messages: [ - ...(s.tabs.find((t) => t.id === activeTabId)?.messages ?? []), - userMessage, - ], + messages: [...(currentTab?.messages ?? []), userMessage], + projectPath, + sessionId: resumeSessionId, + providerKey: requestProviderKey, + sessionProviderKey: requestProviderKey, isStreaming: true, + streamingStartedAt, error: null, + pendingTemporaryFilePaths: temporaryFilePaths, }; if (tabTitle) tabUpdates.title = tabTitle; return { ...applyTabUpdate(s, activeTabId, tabUpdates), + activeProjectPath: projectPath, _cancelledByUser: false, }; }); @@ -374,21 +912,31 @@ export const useClaudeChatStore = create()((set, get) => ({ } prompt = `${ctx}\n\n${userPrompt}`; } + if (switchingDirectProviderToClaudeCode) { + const priorContext = buildProviderSwitchContext( + activeTab?.messages ?? [], + ); + if (priorContext) { + prompt = `${priorContext}\n\n${prompt}`; + } + } log.info("invoking CLI", { promptLength: prompt.length, - mode: sessionId ? "resume" : "new", + mode: resumeSessionId ? "resume" : "new", }); try { - if (sessionId) { + if (resumeSessionId) { // Resume existing session await invoke("resume_claude_code", { projectPath, - sessionId, + sessionId: resumeSessionId, prompt, tabId: activeTabId, model: selectedModel, effortLevel, + providerCredentialId, + providerModelOverride, }); } else { // New session @@ -398,6 +946,8 @@ export const useClaudeChatStore = create()((set, get) => ({ tabId: activeTabId, model: selectedModel, effortLevel, + providerCredentialId, + providerModelOverride, }); } log.info( @@ -411,21 +961,199 @@ export const useClaudeChatStore = create()((set, get) => ({ set((s) => applyTabUpdate(s, activeTabId, { isStreaming: false, + streamingStartedAt: null, error: err?.message || String(err), }), ); } }, - cancelExecution: async () => { - const { activeTabId } = get(); + queueGuidance: (tabId, prompt, contextOverride) => { + const trimmed = prompt.trim(); + if (!trimmed) return; + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab) return {}; + const queuedGuidance = [ + ...(tab.queuedGuidance ?? []), + { + id: nextGuidanceId(), + prompt: trimmed, + contextOverride, + createdAt: Date.now(), + }, + ]; + return applyTabUpdate(state, tabId, { queuedGuidance }); + }); + }, + + consumeQueuedGuidance: (tabId, guidanceId) => { + const state = get(); + const tab = state.tabs.find((t) => t.id === tabId); + const queue = tab?.queuedGuidance ?? []; + const displayedIndex = queue.findIndex( + (guidance) => guidance.displayedInChat, + ); + const targetIndex = guidanceId + ? queue.findIndex((guidance) => guidance.id === guidanceId) + : displayedIndex >= 0 + ? displayedIndex + : 0; + const next = targetIndex >= 0 ? queue[targetIndex] : null; + if (!next) { + if (tab?.forceQueuedGuidanceOnComplete) { + set((s) => + applyTabUpdate(s, tabId, { + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, + }), + ); + } + return null; + } + const rest = queue.filter((_, index) => index !== targetIndex); + set((s) => + applyTabUpdate(s, tabId, { + queuedGuidance: rest, + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, + }), + ); + return next; + }, + + displayQueuedGuidanceInChat: (tabId, guidanceId) => { + let displayedId: string | null = null; + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + const queue = tab?.queuedGuidance ?? []; + const targetId = guidanceId ?? queue[0]?.id; + if (!tab || !targetId || queue.length === 0) return {}; + displayedId = targetId; + return applyTabUpdate(state, tabId, { + queuedGuidance: queue.map((guidance) => ({ + ...guidance, + displayedInChat: guidance.displayedInChat || guidance.id === targetId, + })), + }); + }); + return displayedId; + }, + + removeQueuedGuidance: (tabId, guidanceId) => { + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab) return {}; + const queuedGuidance = (tab.queuedGuidance ?? []).filter( + (guidance) => guidance.id !== guidanceId, + ); + const nextForcedGuidanceId = + tab.forcedQueuedGuidanceId === guidanceId + ? (queuedGuidance.find((guidance) => guidance.displayedInChat)?.id ?? + null) + : tab.forcedQueuedGuidanceId; + return applyTabUpdate(state, tabId, { + queuedGuidance, + ...(tab.forcedQueuedGuidanceId === guidanceId + ? { + forceQueuedGuidanceOnComplete: nextForcedGuidanceId !== null, + forcedQueuedGuidanceId: nextForcedGuidanceId, + } + : {}), + }); + }); + }, + + clearQueuedGuidance: (tabId) => { + set((state) => + applyTabUpdate(state, tabId, { + queuedGuidance: [], + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, + }), + ); + }, + + consumeTemporaryFilePaths: (tabId) => { + const paths = + get().tabs.find((tab) => tab.id === tabId)?.pendingTemporaryFilePaths ?? + []; + if (paths.length > 0) { + set((state) => + applyTabUpdate(state, tabId, { pendingTemporaryFilePaths: [] }), + ); + } + return paths; + }, + + forceQueuedGuidanceNow: async (tabId, guidanceId) => { + const tab = get().tabs.find((t) => t.id === tabId); + if (!tab?.isStreaming || !(tab.queuedGuidance?.length ?? 0)) return; + const targetId = get().displayQueuedGuidanceInChat(tabId, guidanceId); + if (!targetId) return; + + set((state) => { + const currentTab = state.tabs.find((t) => t.id === tabId); + const existingForcedId = currentTab?.forcedQueuedGuidanceId ?? null; + const existingForcedStillQueued = (currentTab?.queuedGuidance ?? []).some( + (guidance) => guidance.id === existingForcedId, + ); + return applyTabUpdate(state, tabId, { + forceQueuedGuidanceOnComplete: true, + forcedQueuedGuidanceId: existingForcedStillQueued + ? existingForcedId + : targetId, + }); + }); + + try { + const interrupted = await invoke("interrupt_claude_execution", { + tabId, + }); + if (interrupted) { + set({ _cancelledByUser: true }); + } + } catch (err: any) { + set((state) => { + const currentTab = state.tabs.find((t) => t.id === tabId); + const existingForcedId = currentTab?.forcedQueuedGuidanceId ?? null; + const nextForcedId = + existingForcedId && existingForcedId !== targetId + ? existingForcedId + : null; + return applyTabUpdate(state, tabId, { + queuedGuidance: (currentTab?.queuedGuidance ?? []).map((guidance) => + guidance.id === targetId + ? { ...guidance, displayedInChat: false } + : guidance, + ), + forceQueuedGuidanceOnComplete: nextForcedId !== null, + forcedQueuedGuidanceId: nextForcedId, + error: err?.message || String(err), + }); + }); + } + }, + + cancelExecution: async (tabId) => { + const activeTabId = tabId ?? get().activeTabId; + const tab = get().tabs.find((t) => t.id === activeTabId); + if (!tab?.isStreaming) return; set({ _cancelledByUser: true }); + set((s) => + applyTabUpdate(s, activeTabId, { + isStreaming: false, + streamingStartedAt: null, + queuedGuidance: [], + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, + }), + ); try { await invoke("cancel_claude_execution", { tabId: activeTabId }); } catch { - // ignore + // The UI has already moved to a stopped state; stale output is ignored. } - set((s) => applyTabUpdate(s, activeTabId, { isStreaming: false })); }, clearMessages: () => { @@ -434,44 +1162,192 @@ export const useClaudeChatStore = create()((set, get) => ({ applyTabUpdate(s, activeTabId, { messages: [], error: null, + streamingStartedAt: null, totalInputTokens: 0, totalOutputTokens: 0, + queuedGuidance: [], + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, }), ); }, + resetForProject: (projectPath) => { + const state = get(); + const tabsAlreadyScoped = + state.activeProjectPath === projectPath && + state.tabs.every((tab) => tab.projectPath === projectPath); + if (tabsAlreadyScoped) return; + + const id = nextTabId(); + const tab = makeDefaultTab(id, projectPath); + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(tab.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); + + set({ + tabs: [tab], + activeTabId: id, + activeProjectPath: projectPath, + messages: tab.messages, + sessionId: tab.sessionId, + isStreaming: tab.isStreaming, + streamingStartedAt: tab.streamingStartedAt, + error: tab.error, + totalInputTokens: tab.totalInputTokens, + totalOutputTokens: tab.totalOutputTokens, + pendingAttachments: [], + pendingPinnedContextRemovalLabels: [], + selectedProviderCredentialId: nextSelectedProviderCredentialId, + _cancelledByUser: false, + }); + }, + newSession: () => { log.info("Starting new session"); - const { activeTabId } = get(); - set((s) => - applyTabUpdate(s, activeTabId, { + const { activeTabId, tabs } = get(); + const activeTab = tabs.find((t) => t.id === activeTabId); + const projectPath = + get().activeProjectPath ?? + useDocumentStore.getState().projectRoot ?? + null; + if (activeTab?.isStreaming) { + const id = nextTabId(); + const newTab = { + ...makeDefaultTab(id, projectPath), + providerKey: + activeTab.providerKey ?? + providerKeyForSelectedCredential(get().selectedProviderCredentialId), + }; + set({ + tabs: [...tabs, newTab], + activeTabId: id, + activeProjectPath: projectPath, + messages: newTab.messages, + sessionId: newTab.sessionId, + isStreaming: newTab.isStreaming, + streamingStartedAt: newTab.streamingStartedAt, + error: newTab.error, + totalInputTokens: newTab.totalInputTokens, + totalOutputTokens: newTab.totalOutputTokens, + selectedProviderCredentialId: selectedCredentialForProviderKey( + newTab.providerKey, + ), + }); + return; + } + + set((s) => ({ + ...applyTabUpdate(s, activeTabId, { messages: [], sessionId: null, + projectPath, + providerKey: + activeTab?.providerKey ?? + providerKeyForSelectedCredential(s.selectedProviderCredentialId), + sessionProviderKey: null, error: null, isStreaming: false, + streamingStartedAt: null, totalInputTokens: 0, totalOutputTokens: 0, title: "New Chat", + queuedGuidance: [], + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, }), - ); + activeProjectPath: projectPath, + })); }, - resumeSession: async (sessionId: string) => { + resumeSession: async (sessionId: string, title?: string) => { log.info(`Resuming session: ${sessionId.slice(0, 8)}`); - const { activeTabId } = get(); + const sessionTitle = title?.trim() || undefined; const projectPath = useDocumentStore.getState().projectRoot; + const state = get(); + let { activeTabId } = state; + let { tabs } = state; + const existingTab = tabs.find( + (tab) => tab.sessionId === sessionId && tab.projectPath === projectPath, + ); + + if (existingTab) { + const nextTitle = sessionTitle ?? existingTab.title; + activeTabId = existingTab.id; + const nextTabs = tabs.map((tab) => + tab.id === existingTab.id ? { ...tab, title: nextTitle } : tab, + ); + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(existingTab.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); + set({ + tabs: nextTabs, + activeTabId: existingTab.id, + activeProjectPath: projectPath ?? existingTab.projectPath, + messages: existingTab.messages, + sessionId: existingTab.sessionId, + isStreaming: existingTab.isStreaming, + streamingStartedAt: existingTab.streamingStartedAt, + error: existingTab.error, + totalInputTokens: existingTab.totalInputTokens, + totalOutputTokens: existingTab.totalOutputTokens, + selectedProviderCredentialId: nextSelectedProviderCredentialId, + }); + if (existingTab.isStreaming) return; + } else { + const activeTab = tabs.find((tab) => tab.id === activeTabId); + if (activeTab?.isStreaming) { + const id = nextTabId(); + const newTab = { + ...makeDefaultTab(id, projectPath ?? state.activeProjectPath), + providerKey: + activeTab.providerKey ?? + providerKeyForSelectedCredential( + get().selectedProviderCredentialId, + ), + }; + tabs = [...tabs, newTab]; + activeTabId = id; + set({ + tabs, + activeTabId, + activeProjectPath: projectPath ?? newTab.projectPath, + messages: newTab.messages, + sessionId: newTab.sessionId, + isStreaming: newTab.isStreaming, + streamingStartedAt: newTab.streamingStartedAt, + error: newTab.error, + totalInputTokens: newTab.totalInputTokens, + totalOutputTokens: newTab.totalOutputTokens, + selectedProviderCredentialId: selectedCredentialForProviderKey( + newTab.providerKey, + ), + }); + } + } // Reset state with new session ID - set((s) => - applyTabUpdate(s, activeTabId, { + set((s) => ({ + ...applyTabUpdate(s, activeTabId, { messages: [], + projectPath: projectPath ?? null, sessionId, + providerKey: null, + sessionProviderKey: null, error: null, isStreaming: false, + streamingStartedAt: null, totalInputTokens: 0, totalOutputTokens: 0, + title: sessionTitle ?? "New Chat", + queuedGuidance: [], + forceQueuedGuidanceOnComplete: false, + forcedQueuedGuidanceId: null, }), - ); + activeProjectPath: projectPath ?? null, + })); // Load session history from JSONL file if (projectPath) { @@ -482,15 +1358,43 @@ export const useClaudeChatStore = create()((set, get) => ({ }); // Filter to displayable message types and map to ClaudeStreamMessage - const messages: ClaudeStreamMessage[] = []; + const rawMessages: ClaudeStreamMessage[] = []; for (const entry of history) { const type = entry.type; if (type === "user" || type === "assistant" || type === "result") { - messages.push(entry as ClaudeStreamMessage); + rawMessages.push(entry as ClaudeStreamMessage); } } - set((s) => applyTabUpdate(s, activeTabId, { messages })); + const messages = rawMessages.map(sanitizeStoredUserMessageForDisplay); + const totals = usageTotalsForMessages(messages); + const providerKey = inferProviderKeyFromHistory(history); + const selectedProviderCredentialId = + providerCredentialIdFromSessionKey(providerKey); + const nextSelectedProviderCredentialId = + selectedProviderCredentialId === undefined + ? CLAUDE_CODE_PROVIDER_ID + : selectedProviderCredentialId; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); + set((s) => ({ + ...applyTabUpdate(s, activeTabId, { + messages, + providerKey: + providerKey ?? + providerKeyForSelectedCredential( + nextSelectedProviderCredentialId, + ), + sessionProviderKey: + providerKey ?? + providerKeyForSelectedCredential( + nextSelectedProviderCredentialId, + ), + title: sessionTitle ?? titleForMessages(rawMessages) ?? "New Chat", + totalInputTokens: totals.inputTokens, + totalOutputTokens: totals.outputTokens, + }), + selectedProviderCredentialId: nextSelectedProviderCredentialId, + })); } catch (err) { log.error("Failed to load session history", { error: String(err) }); } @@ -502,17 +1406,33 @@ export const useClaudeChatStore = create()((set, get) => ({ createTab: () => { log.debug("Creating new tab"); const id = nextTabId(); - const newTab = makeDefaultTab(id); + const state = get(); + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + const projectPath = + state.activeProjectPath ?? + useDocumentStore.getState().projectRoot ?? + null; + const newTab = { + ...makeDefaultTab(id, projectPath), + providerKey: + activeTab?.providerKey ?? + providerKeyForSelectedCredential(state.selectedProviderCredentialId), + }; set((s) => ({ tabs: [...s.tabs, newTab], activeTabId: id, + activeProjectPath: projectPath, // Project new tab fields to top-level messages: newTab.messages, sessionId: newTab.sessionId, isStreaming: newTab.isStreaming, + streamingStartedAt: newTab.streamingStartedAt, error: newTab.error, totalInputTokens: newTab.totalInputTokens, totalOutputTokens: newTab.totalOutputTokens, + selectedProviderCredentialId: selectedCredentialForProviderKey( + newTab.providerKey, + ), })); return id; }, @@ -534,16 +1454,23 @@ export const useClaudeChatStore = create()((set, get) => ({ // Switch to adjacent tab const newIdx = Math.min(idx, newTabs.length - 1); const newActive = newTabs[newIdx]; + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(newActive.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); set({ tabs: newTabs, activeTabId: newActive.id, + activeProjectPath: newActive.projectPath, // Project new active tab messages: newActive.messages, sessionId: newActive.sessionId, isStreaming: newActive.isStreaming, + streamingStartedAt: newActive.streamingStartedAt, error: newActive.error, totalInputTokens: newActive.totalInputTokens, totalOutputTokens: newActive.totalOutputTokens, + selectedProviderCredentialId: nextSelectedProviderCredentialId, }); } else { set({ tabs: newTabs }); @@ -555,16 +1482,23 @@ export const useClaudeChatStore = create()((set, get) => ({ if (tabId === state.activeTabId) return; const targetTab = state.tabs.find((t) => t.id === tabId); if (!targetTab) return; + const nextSelectedProviderCredentialId = + selectedCredentialForProviderKey(targetTab.providerKey) ?? + CLAUDE_CODE_PROVIDER_ID; + persistSelectedProviderCredentialId(nextSelectedProviderCredentialId); // Project the target tab's fields to top-level set({ activeTabId: tabId, + activeProjectPath: targetTab.projectPath, messages: targetTab.messages, sessionId: targetTab.sessionId, isStreaming: targetTab.isStreaming, + streamingStartedAt: targetTab.streamingStartedAt, error: targetTab.error, totalInputTokens: targetTab.totalInputTokens, totalOutputTokens: targetTab.totalOutputTokens, + selectedProviderCredentialId: nextSelectedProviderCredentialId, }); }, @@ -578,17 +1512,45 @@ export const useClaudeChatStore = create()((set, get) => ({ _appendMessage: (tabId: string, msg: ClaudeStreamMessage) => { set((state) => { - let inputDelta = 0; - let outputDelta = 0; - const usage = msg.usage || msg.message?.usage; - if (usage) { - inputDelta = usage.input_tokens || 0; - outputDelta = usage.output_tokens || 0; - } + const { input_tokens: inputDelta, output_tokens: outputDelta } = + usageFromMessage(msg); const tab = state.tabs.find((t) => t.id === tabId); if (!tab) return {}; + if (msg.type === "assistant" && msg.subtype === "streaming_delta") { + const last = tab.messages[tab.messages.length - 1]; + if (last?.type === "assistant" && last.subtype === "streaming_delta") { + const existing = last.message?.content ?? []; + const incoming = msg.message?.content ?? []; + if (incoming.length > 0) { + const merged: ClaudeStreamMessage = { + ...last, + message: { + ...last.message, + content: mergeStreamingContent(existing, incoming), + }, + }; + return applyTabUpdate(state, tabId, { + messages: [...tab.messages.slice(0, -1), merged], + totalInputTokens: tab.totalInputTokens + inputDelta, + totalOutputTokens: tab.totalOutputTokens + outputDelta, + }); + } + } + } + + if (msg.type === "assistant" && msg.subtype === "streaming_final") { + const last = tab.messages[tab.messages.length - 1]; + if (last?.type === "assistant" && last.subtype === "streaming_delta") { + return applyTabUpdate(state, tabId, { + messages: [...tab.messages.slice(0, -1), msg], + totalInputTokens: tab.totalInputTokens + inputDelta, + totalOutputTokens: tab.totalOutputTokens + outputDelta, + }); + } + } + return applyTabUpdate(state, tabId, { messages: [...tab.messages, msg], totalInputTokens: tab.totalInputTokens + inputDelta, @@ -601,8 +1563,29 @@ export const useClaudeChatStore = create()((set, get) => ({ set((state) => applyTabUpdate(state, tabId, { sessionId: id })); }, + _setSessionTitle: (sessionId: string, title: string) => { + const cleanTitle = title.trim(); + if (!cleanTitle) return; + set((state) => ({ + tabs: state.tabs.map((tab) => + tab.sessionId === sessionId && + tab.projectPath === state.activeProjectPath + ? { ...tab, title: cleanTitle } + : tab, + ), + })); + }, + _setStreaming: (tabId: string, streaming: boolean) => { - set((state) => applyTabUpdate(state, tabId, { isStreaming: streaming })); + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + return applyTabUpdate(state, tabId, { + isStreaming: streaming, + streamingStartedAt: streaming + ? (tab?.streamingStartedAt ?? Date.now()) + : null, + }); + }); }, _setError: (tabId: string, error: string | null) => { diff --git a/apps/desktop/src/stores/claude-setup-store.ts b/apps/desktop/src/stores/claude-setup-store.ts index 8126d64..bb52c51 100644 --- a/apps/desktop/src/stores/claude-setup-store.ts +++ b/apps/desktop/src/stores/claude-setup-store.ts @@ -1,5 +1,13 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; +import { + isChatModelOption, + modelInfoId, + type OpenAiCompatibleModelInfo, + rememberModelListCapabilityMetadata, +} from "@/lib/model-capabilities"; + +const MOONSHOT_OFFICIAL_ORIGIN = "https://api.moonshot.ai"; // ─── Types ─── @@ -8,10 +16,21 @@ interface ClaudeStatus { authenticated: boolean; binary_path: string | null; version: string | null; + provider_kind: "claude-code" | "openai-compatible" | null; account_email: string | null; + provider_model: string | null; + provider_base_url: string | null; + claude_provider_configured: boolean; missing_git: boolean; } +export interface OpenAiCompatibleCredentialInfo { + id: string; + label: string; + base_url: string; + model: string; +} + type SetupStatus = | "checking" | "missing-git" @@ -32,9 +51,17 @@ interface ClaudeSetupState { status: SetupStatus; isInstalling: boolean; isLoggingIn: boolean; + isSavingApiKey: boolean; + isClearingApiKey: boolean; error: string | null; version: string | null; + providerKind: "claude-code" | "openai-compatible" | null; accountEmail: string | null; + providerModel: string | null; + providerBaseUrl: string | null; + claudeProviderConfigured: boolean; + openAiCredentials: OpenAiCompatibleCredentialInfo[]; + activeOpenAiCredentialId: string | null; // Install progress installSteps: StepInfo[]; @@ -48,6 +75,18 @@ interface ClaudeSetupState { checkStatus: () => Promise; install: () => Promise; login: () => Promise; + saveApiKey: ( + apiKey: string, + baseUrl?: string, + provider?: string, + model?: string, + credentialLabel?: string, + ) => Promise; + clearApiKey: () => Promise; + listApiCredentials: () => Promise; + deleteApiCredential: (credentialId: string) => Promise; + setActiveApiCredential: (credentialId: string) => Promise; + fetchProviderModels: (apiKey: string, baseUrl: string) => Promise; toggleInstallLogs: () => void; // Internal helpers @@ -83,6 +122,59 @@ const STEP_ORDER_INSTALL = [ ]; const STEP_ORDER_LOGIN = ["opening-browser", "waiting-auth", "complete"]; +function canonicalOpenAiCompatibleBaseUrl(url: string) { + const trimmed = url.trim(); + const lower = trimmed.toLowerCase(); + const deepseekMatch = trimmed.match( + /^(https?:\/\/api\.deepseek\.com)(?:\/|$)/i, + ); + const deepseekOrigin = deepseekMatch?.[1]; + if (deepseekOrigin && !lower.includes("/anthropic")) { + return `${deepseekOrigin}/anthropic`; + } + if (deepseekOrigin) { + const anthropicIndex = lower.indexOf("/anthropic"); + return `${trimmed.slice(0, anthropicIndex)}/anthropic`; + } + + const qwenMatch = trimmed.match( + /^(https?:\/\/dashscope(?:-intl)?\.aliyuncs\.com)(?:\/|$)/i, + ); + const qwenOrigin = qwenMatch?.[1]; + if ( + qwenOrigin && + (lower.includes("/apps/anthropic") || + lower.includes("/compatible-mode/") || + trimmed.replace(/\/+$/, "").toLowerCase() === qwenOrigin.toLowerCase()) + ) { + const anthropicIndex = lower.indexOf("/apps/anthropic"); + if (anthropicIndex >= 0) { + return `${trimmed.slice(0, anthropicIndex)}/apps/anthropic`; + } + return `${qwenOrigin}/apps/anthropic`; + } + + const moonshotMatch = trimmed.match( + /^(https?:\/\/api\.moonshot\.(?:cn|ai))(?:\/|$)/i, + ); + const moonshotOrigin = moonshotMatch?.[1]; + if ( + moonshotOrigin && + (lower.includes("/anthropic") || + lower.includes("/v1") || + trimmed.replace(/\/+$/, "").toLowerCase() === + moonshotOrigin.toLowerCase()) + ) { + const anthropicIndex = lower.indexOf("/anthropic"); + if (anthropicIndex >= 0) { + return `${MOONSHOT_OFFICIAL_ORIGIN}/anthropic`; + } + return `${MOONSHOT_OFFICIAL_ORIGIN}/anthropic`; + } + + return trimmed; +} + function advanceSteps( steps: StepInfo[], targetId: string, @@ -107,9 +199,17 @@ export const useClaudeSetupStore = create((set, get) => ({ status: "checking", isInstalling: false, isLoggingIn: false, + isSavingApiKey: false, + isClearingApiKey: false, error: null, version: null, + providerKind: null, accountEmail: null, + providerModel: null, + providerBaseUrl: null, + claudeProviderConfigured: false, + openAiCredentials: [], + activeOpenAiCredentialId: null, installSteps: [], installLogs: [], @@ -121,15 +221,51 @@ export const useClaudeSetupStore = create((set, get) => ({ set({ status: "checking", error: null }); try { const result = await invoke("check_claude_status"); + let openAiCredentials: OpenAiCompatibleCredentialInfo[] = []; + try { + openAiCredentials = await invoke( + "list_openai_compatible_credentials", + ); + } catch { + openAiCredentials = []; + } + const activeOpenAiCredentialId = + openAiCredentials.find( + (credential) => + credential.model === result.provider_model && + credential.base_url === result.provider_base_url, + )?.id ?? + openAiCredentials[0]?.id ?? + null; // On Windows, Git for Windows is required before anything else if (result.missing_git) { - set({ status: "missing-git", version: null, accountEmail: null }); + set({ + status: "missing-git", + version: null, + providerKind: result.provider_kind ?? "claude-code", + accountEmail: null, + providerModel: null, + providerBaseUrl: null, + claudeProviderConfigured: result.claude_provider_configured, + openAiCredentials, + activeOpenAiCredentialId, + }); return; } if (!result.installed) { - set({ status: "not-installed", version: null, accountEmail: null }); + set({ + status: "not-installed", + version: null, + providerKind: result.provider_kind ?? "claude-code", + accountEmail: null, + providerModel: null, + providerBaseUrl: null, + claudeProviderConfigured: result.claude_provider_configured, + openAiCredentials, + activeOpenAiCredentialId, + }); return; } @@ -137,7 +273,13 @@ export const useClaudeSetupStore = create((set, get) => ({ set({ status: "not-authenticated", version: result.version, + providerKind: result.provider_kind ?? "claude-code", accountEmail: null, + providerModel: null, + providerBaseUrl: null, + claudeProviderConfigured: result.claude_provider_configured, + openAiCredentials, + activeOpenAiCredentialId, }); return; } @@ -145,7 +287,13 @@ export const useClaudeSetupStore = create((set, get) => ({ set({ status: "ready", version: result.version, + providerKind: result.provider_kind ?? "claude-code", accountEmail: result.account_email, + providerModel: result.provider_model, + providerBaseUrl: result.provider_base_url, + claudeProviderConfigured: result.claude_provider_configured, + openAiCredentials, + activeOpenAiCredentialId, }); } catch (err: any) { set({ @@ -171,7 +319,10 @@ export const useClaudeSetupStore = create((set, get) => ({ try { // Fire-and-forget — events drive the rest - await invoke("install_claude_cli"); + const success = await invoke("install_claude_cli"); + if (get().isInstalling) { + get()._finishInstall(success); + } } catch (err: any) { set({ isInstalling: false, @@ -204,6 +355,174 @@ export const useClaudeSetupStore = create((set, get) => ({ } }, + saveApiKey: async ( + apiKey: string, + baseUrl?: string, + provider = "claude-code", + model?: string, + credentialLabel?: string, + ) => { + const status = get().status; + if (status === "missing-git" || status === "not-installed") { + set({ + error: "Install Claude Code before configuring an AI provider.", + }); + return false; + } + + const key = apiKey.trim(); + const rawUrl = baseUrl?.trim() ?? ""; + const url = + provider === "openai-compatible" + ? canonicalOpenAiCompatibleBaseUrl(rawUrl) + : rawUrl; + const modelName = model?.trim() ?? ""; + if (provider !== "openai-compatible" && !key) { + set({ error: "API key is empty" }); + return false; + } + + if (key && /\s/.test(key)) { + set({ error: "API key cannot contain spaces or line breaks" }); + return false; + } + + if (url && !/^https?:\/\//.test(url)) { + set({ error: "Base URL must start with http:// or https://" }); + return false; + } + + if (provider === "openai-compatible" && !url) { + set({ error: "OpenAI-compatible provider requires a Base URL." }); + return false; + } + + if (provider === "openai-compatible" && !modelName) { + set({ error: "OpenAI-compatible provider requires a model." }); + return false; + } + + if ( + provider !== "openai-compatible" && + !url && + !key.startsWith("sk-ant-") + ) { + set({ + error: + "This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-.", + }); + return false; + } + + set({ isSavingApiKey: true, error: null }); + try { + if (provider === "openai-compatible") { + await invoke("verify_openai_compatible_api_key", { + apiKey: key, + baseUrl: url, + model: modelName, + }); + } + + await invoke("save_anthropic_api_key", { + apiKey: key, + baseUrl: url || null, + provider, + model: modelName || null, + credentialLabel: credentialLabel || null, + }); + set({ isSavingApiKey: false }); + await get().checkStatus(); + return true; + } catch (err: any) { + set({ + isSavingApiKey: false, + error: err?.message || String(err), + }); + return false; + } + }, + + clearApiKey: async () => { + set({ isClearingApiKey: true, error: null }); + try { + await invoke("clear_anthropic_api_key"); + set({ isClearingApiKey: false }); + await get().checkStatus(); + return true; + } catch (err: any) { + set({ + isClearingApiKey: false, + error: err?.message || String(err), + }); + return false; + } + }, + + listApiCredentials: async () => { + const credentials = await invoke( + "list_openai_compatible_credentials", + ); + set((state) => ({ + openAiCredentials: credentials, + activeOpenAiCredentialId: + credentials.find( + (credential) => + credential.model === state.providerModel && + credential.base_url === state.providerBaseUrl, + )?.id ?? + credentials[0]?.id ?? + null, + })); + }, + + deleteApiCredential: async (credentialId: string) => { + try { + await invoke("delete_openai_compatible_credential", { + credentialId, + }); + await get().checkStatus(); + return true; + } catch (err: any) { + set({ error: err?.message || String(err) }); + return false; + } + }, + + setActiveApiCredential: async (credentialId: string) => { + try { + await invoke("set_active_openai_compatible_credential", { + credentialId, + }); + await get().checkStatus(); + return true; + } catch (err: any) { + set({ error: err?.message || String(err) }); + return false; + } + }, + + fetchProviderModels: async (apiKey: string, baseUrl: string) => { + const trimmedBaseUrl = baseUrl.trim(); + const models = await invoke>( + "list_openai_compatible_models", + { + apiKey: apiKey.trim(), + baseUrl: trimmedBaseUrl, + }, + ); + rememberModelListCapabilityMetadata(trimmedBaseUrl, models); + return models + .filter((model) => + isChatModelOption({ + baseUrl: trimmedBaseUrl, + model: modelInfoId(model), + metadata: typeof model === "string" ? undefined : model.metadata, + }), + ) + .map(modelInfoId); + }, + toggleInstallLogs: () => { set((state) => ({ installLogsVisible: !state.installLogsVisible })); }, diff --git a/apps/desktop/src/stores/document-store.ts b/apps/desktop/src/stores/document-store.ts index 12c582d..ed268dd 100644 --- a/apps/desktop/src/stores/document-store.ts +++ b/apps/desktop/src/stores/document-store.ts @@ -22,9 +22,11 @@ import { clearDocCache } from "@/lib/mupdf/pdf-doc-cache"; import { clearScrollPositionCache } from "@/components/workspace/preview/pdf-viewer"; import { clearZoomCache } from "@/components/workspace/preview/pdf-preview"; import { clearEditorStateCache } from "@/components/workspace/editor/latex-editor"; +import { useProjectStore } from "@/stores/project-store"; import { createLogger } from "@/lib/debug/logger"; const log = createLogger("document"); +const PROJECT_RENAME_LOCK_RETRY_DELAYS_MS = [150, 300, 600, 1000]; export interface ProjectFile { id: string; // relativePath is the id @@ -57,6 +59,11 @@ export function getCurrentPdfBytes(): Uint8Array | null { : null; } +/** Get the root file id for the currently displayed PDF, if any. */ +export function getCurrentPdfRootId(): string | null { + return _currentPdfRootId; +} + /** Check if any PDF data exists for the current root. */ export function hasPdfData(): boolean { return _currentPdfRootId != null && _pdfBytesCache.has(_currentPdfRootId); @@ -92,6 +99,7 @@ interface DocumentState { lastCompiledGenerations: Map; openProject: (rootPath: string) => Promise; + renameProject: (newName: string) => Promise; closeProject: () => void; setActiveFile: (id: string) => void; addFile: (file: Omit) => string; @@ -226,6 +234,104 @@ function migrateCacheKey( return copy; } +function normalizeProjectRoot(rootPath: string): string { + return rootPath.replace(/[\\/]+$/, ""); +} + +function splitProjectRoot(rootPath: string): { + parentPath: string; + folderName: string; + separator: string; +} { + const normalized = normalizeProjectRoot(rootPath); + const lastSep = Math.max( + normalized.lastIndexOf("/"), + normalized.lastIndexOf("\\"), + ); + if (lastSep < 0) { + throw new Error("Project path has no parent folder"); + } + + const separator = normalized[lastSep]; + return { + parentPath: lastSep === 0 ? separator : normalized.slice(0, lastSep), + folderName: normalized.slice(lastSep + 1), + separator, + }; +} + +function buildRenamedProjectRoot(rootPath: string, newName: string): string { + const name = newName.trim(); + if (!name) throw new Error("Project name cannot be empty"); + if (name === "." || name === "..") { + throw new Error("Project name cannot be . or .."); + } + if (/[\\/<>:"|?*]/.test(name) || /[\s.]$/.test(name)) { + throw new Error("Project name contains characters Windows cannot use"); + } + + const { parentPath, folderName, separator } = splitProjectRoot(rootPath); + if (name === folderName) return normalizeProjectRoot(rootPath); + return `${parentPath}${parentPath.endsWith(separator) ? "" : separator}${name}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isWindowsFolderLockError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("os error 32") || + message.includes("being used by another process") || + message.includes("another program is using") || + message.includes("进程无法访问") || + message.includes("另一个程序正在使用") + ); +} + +function formatProjectRenameError(error: unknown): string { + if (isWindowsFolderLockError(error)) { + return [ + "Project folder is still in use.", + "Close any external PDF viewer, terminal, Python process, or file explorer preview using this project, then try again.", + ].join(" "); + } + return error instanceof Error ? error.message : String(error); +} + +async function renameProjectRootWithRetry( + oldRoot: string, + newRoot: string, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + await renameFileOnDisk(oldRoot, newRoot); + return; + } catch (error) { + const delay = PROJECT_RENAME_LOCK_RETRY_DELAYS_MS[attempt]; + if (!isWindowsFolderLockError(error) || delay == null) { + throw new Error(formatProjectRenameError(error)); + } + await sleep(delay); + } + } +} + +async function waitForCompileToFinish( + getState: () => DocumentState, +): Promise { + const started = Date.now(); + while (getState().isCompiling) { + if (Date.now() - started > 10_000) { + throw new Error( + "Compilation is still running. Wait for it to finish before renaming the project.", + ); + } + await sleep(200); + } +} + // Auto-save: debounced save 2 seconds after last content change let autoSaveTimer: ReturnType | null = null; // Store reference set after creation to avoid TDZ issues @@ -349,13 +455,83 @@ export const useDocumentStore = create()((set, get) => ({ }); }, + renameProject: async (newName: string) => { + const state = get(); + if (!state.projectRoot) throw new Error("No project open"); + + const oldRoot = state.projectRoot; + const newRoot = buildRenamedProjectRoot(oldRoot, newName); + if (newRoot === normalizeProjectRoot(oldRoot)) return; + + if (autoSaveTimer) { + clearTimeout(autoSaveTimer); + autoSaveTimer = null; + } + await waitForCompileToFinish(get); + + const chatState = useClaudeChatStore.getState(); + const streamingTabs = + "tabs" in chatState && Array.isArray(chatState.tabs) + ? chatState.tabs.filter((tab) => tab.isStreaming) + : []; + if (streamingTabs.length > 0) { + await Promise.all( + streamingTabs.map((tab) => + invoke("cancel_claude_execution", { tabId: tab.id }).catch(() => {}), + ), + ); + await sleep(250); + } + + await state.saveAllFiles(); + const dirtyFiles = get().files.filter( + (f) => f.isDirty && f.content != null, + ); + if (dirtyFiles.length > 0) { + throw new Error("Save failed. Please save changes before renaming."); + } + + clearPdfBytesCache(); + clearScrollPositionCache(); + clearZoomCache(); + clearEditorStateCache(); + useHistoryStore.getState().reset(); + set((s) => ({ + pdfRevision: s.pdfRevision + 1, + compileError: null, + compileErrorCache: new Map(), + lastCompiledGenerations: new Map(), + })); + await clearDocCache(); + await sleep(150); + + await renameProjectRootWithRetry(oldRoot, newRoot); + try { + await invoke("migrate_project_sessions", { + oldProjectPath: oldRoot, + newProjectPath: newRoot, + }); + } catch (err) { + log.warn("Failed to migrate project sessions after rename", { + oldRoot, + newRoot, + error: String(err), + }); + } + const projectStore = useProjectStore.getState(); + projectStore.renameRecentProject(oldRoot, newRoot); + projectStore.setLastProjectFolder(splitProjectRoot(newRoot).parentPath); + + await get().openProject(newRoot); + }, + closeProject: () => { log.info("Closing project"); if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; } - clearDocCache(); + void clearDocCache(); clearScrollPositionCache(); clearZoomCache(); clearEditorStateCache(); @@ -377,6 +553,15 @@ export const useDocumentStore = create()((set, get) => ({ setActiveFile: (id) => { const state = get(); + const file = state.files.find((f) => f.id === id); + if (!file || file.type !== "tex") { + set({ + activeFileId: id, + selectionRange: null, + }); + return; + } + const rootId = resolveTexRoot(id, state.files); const newPdfRootId = _pdfBytesCache.has(rootId) ? rootId : null; const pdfRootChanged = newPdfRootId !== _currentPdfRootId; diff --git a/apps/desktop/src/stores/project-store.ts b/apps/desktop/src/stores/project-store.ts index e55e6b0..5d28628 100644 --- a/apps/desktop/src/stores/project-store.ts +++ b/apps/desktop/src/stores/project-store.ts @@ -12,11 +12,28 @@ interface ProjectState { lastProjectFolder: string | null; addRecentProject: (path: string) => void; removeRecentProject: (path: string) => void; + renameRecentProject: (oldPath: string, newPath: string) => void; setLastProjectFolder: (path: string) => void; } const MAX_RECENT = 10; +function normalizeRecentPath(path: string): string { + return path.replace(/[\\/]+$/, ""); +} + +function recentProjectName(path: string): string { + const normalized = normalizeRecentPath(path); + return normalized.split(/[/\\]/).pop() || normalized; +} + +function isSameProjectPath(a: string, b: string): boolean { + return ( + normalizeRecentPath(a).toLowerCase() === + normalizeRecentPath(b).toLowerCase() + ); +} + export const useProjectStore = create()( persist( (set) => ({ @@ -26,12 +43,15 @@ export const useProjectStore = create()( setLastProjectFolder: (path) => set({ lastProjectFolder: path }), addRecentProject: (path) => { - const name = path.split(/[/\\]/).pop() || path; + const normalizedPath = normalizeRecentPath(path); + const name = recentProjectName(normalizedPath); set((state) => { - const filtered = state.recentProjects.filter((p) => p.path !== path); + const filtered = state.recentProjects.filter( + (p) => !isSameProjectPath(p.path, normalizedPath), + ); return { recentProjects: [ - { path, name, lastOpened: Date.now() }, + { path: normalizedPath, name, lastOpened: Date.now() }, ...filtered, ].slice(0, MAX_RECENT), }; @@ -40,12 +60,33 @@ export const useProjectStore = create()( removeRecentProject: (path) => { set((state) => ({ - recentProjects: state.recentProjects.filter((p) => p.path !== path), + recentProjects: state.recentProjects.filter( + (p) => !isSameProjectPath(p.path, path), + ), + })); + }, + + renameRecentProject: (oldPath, newPath) => { + const normalizedNewPath = normalizeRecentPath(newPath); + const name = recentProjectName(normalizedNewPath); + set((state) => ({ + recentProjects: [ + { path: normalizedNewPath, name, lastOpened: Date.now() }, + ...state.recentProjects.filter( + (p) => + !isSameProjectPath(p.path, oldPath) && + !isSameProjectPath(p.path, normalizedNewPath), + ), + ].slice(0, MAX_RECENT), })); }, }), { name: "claude-prism-projects", + partialize: (state) => ({ + recentProjects: state.recentProjects, + lastProjectFolder: state.lastProjectFolder, + }), }, ), ); diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index 7f469fc..cb1f3f5 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -124,6 +124,7 @@ /* macOS overlay titlebar inset — traffic lights safe area */ --titlebar-height: 18px; --traffic-light-width: 70px; + --workspace-topbar-height: 40px; } body { @apply bg-background text-foreground; diff --git a/package.json b/package.json index 50181ee..a1faf13 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@claude-prism/root", "description": "Open-Source AI LaTeX writing workspace", - "version": "1.2.0", + "version": "1.3.0", "repository": { "type": "git", "url": "https://github.com/delibae/claude-prism" @@ -13,8 +13,8 @@ ], "scripts": { "dev:web": "pnpm turbo dev --filter=@claude-prism/web --filter=@claude-prism/latex-api", - "dev:desktop": "pnpm --filter=@claude-prism/desktop-sidecar dev & pnpm --filter=@claude-prism/desktop tauri dev", - "build:desktop": "pnpm --filter=@claude-prism/desktop tauri build", + "dev:desktop": "node scripts/dev-desktop.mjs", + "build:desktop": "node scripts/build-desktop.mjs", "lint": "pnpm exec biome check", "lint:fix": "pnpm exec biome check --fix", "release": "echo 'To release: git tag v && git push origin v'", @@ -35,7 +35,10 @@ "serialize-javascript": ">=6.0.3", "undici": ">=7.24.0", "flatted": ">=3.4.0", - "hono": ">=4.12.7" + "hono": ">=4.12.7", + "ws": ">=8.20.1", + "postcss": ">=8.5.10", + "uuid": ">=11.1.1" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d077a24..f4fa62c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ overrides: undici: '>=7.24.0' flatted: '>=3.4.0' hono: '>=4.12.7' + ws: '>=8.20.1' + postcss: '>=8.5.10' + uuid: '>=11.1.1' importers: @@ -2750,8 +2753,8 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2792,12 +2795,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} property-information@7.1.0: @@ -3148,9 +3147,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true vaul@1.1.2: @@ -3288,8 +3286,8 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4925,7 +4923,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 - postcss: 8.5.6 + postcss: 8.5.15 tailwindcss: 4.1.18 '@tauri-apps/api@2.11.0': {} @@ -5553,7 +5551,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -6034,7 +6032,7 @@ snapshots: mute-stream@2.0.0: optional: true - nanoid@3.3.11: {} + nanoid@3.3.12: {} next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: @@ -6073,15 +6071,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.6: + postcss@8.5.15: dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -6547,7 +6539,7 @@ snapshots: dependencies: react: 19.2.3 - uuid@10.0.0: {} + uuid@14.0.0: {} vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: @@ -6578,7 +6570,7 @@ snapshots: '@rollup/plugin-virtual': 3.0.2(rollup@4.60.1) '@swc/core': 1.15.21 '@swc/wasm': 1.15.21 - uuid: 10.0.0 + uuid: 14.0.0 vite: 6.4.2(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.16.9)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@swc/helpers' @@ -6589,7 +6581,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: @@ -6669,7 +6661,7 @@ snapshots: strip-ansi: 6.0.1 optional: true - ws@8.19.0: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs new file mode 100644 index 0000000..d211aa4 --- /dev/null +++ b/scripts/build-desktop.mjs @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +const env = { ...process.env }; + +function appendEnvFlag(name, flag) { + const current = env[name] ?? ""; + env[name] = current.includes(flag) + ? current + : [current, flag].filter(Boolean).join(" "); +} + +if (process.platform === "win32") { + env.VCPKG_ROOT ||= join(env.USERPROFILE ?? "", "vcpkg"); + env.TECTONIC_DEP_BACKEND = "vcpkg"; + env.VCPKGRS_TRIPLET = "x64-windows-static-release"; + env.VCPKG_DEFAULT_TRIPLET = env.VCPKGRS_TRIPLET; + appendEnvFlag("RUSTFLAGS", "-Ctarget-feature=+crt-static"); + env.CXXFLAGS = [env.CXXFLAGS, "/std:c++17"].filter(Boolean).join(" "); +} + +const args = ["--filter=@claude-prism/desktop", "tauri", "build"]; + +if (!env.TAURI_SIGNING_PRIVATE_KEY) { + args.push("--config", "src-tauri/tauri.local-build.conf.json"); +} + +const child = + process.platform === "win32" + ? spawn( + process.env.ComSpec ?? "cmd.exe", + ["/d", "/s", "/c", `corepack pnpm ${args.join(" ")}`], + { + env, + stdio: "inherit", + }, + ) + : spawn("pnpm", args, { + env, + stdio: "inherit", + }); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs new file mode 100644 index 0000000..85a3b10 --- /dev/null +++ b/scripts/dev-desktop.mjs @@ -0,0 +1,49 @@ +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +const env = { ...process.env }; + +function appendEnvFlag(name, flag) { + const current = env[name] ?? ""; + env[name] = current.includes(flag) + ? current + : [current, flag].filter(Boolean).join(" "); +} + +if (process.platform === "win32") { + env.VCPKG_ROOT ||= join(env.USERPROFILE ?? "", "vcpkg"); + env.TECTONIC_DEP_BACKEND = "vcpkg"; + env.VCPKGRS_TRIPLET = "x64-windows-static-release"; + env.VCPKG_DEFAULT_TRIPLET = env.VCPKGRS_TRIPLET; + appendEnvFlag("RUSTFLAGS", "-Ctarget-feature=+crt-static"); + env.CXXFLAGS = [env.CXXFLAGS, "/std:c++17"].filter(Boolean).join(" "); +} + +const child = + process.platform === "win32" + ? spawn( + process.env.ComSpec ?? "cmd.exe", + [ + "/d", + "/s", + "/c", + "corepack pnpm --filter=@claude-prism/desktop tauri dev", + ], + { + env, + stdio: "inherit", + }, + ) + : spawn("pnpm", ["--filter=@claude-prism/desktop", "tauri", "dev"], { + env, + stdio: "inherit", + }); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +});