From 82c60ee6a8bb8174c617d92aae0c8d5b14bc82a0 Mon Sep 17 00:00:00 2001 From: Shadowfetch Date: Thu, 5 Nov 2026 23:17:33 -0500 Subject: [PATCH] feat: 2.4.0 Apple silicon, T3 first-run, and one-click provider sign-in Open project lands in chat. Connect Codex, Claude, Cursor, Antigravity, and Grok (or paste OpenRouter/xAI/OpenAI keys) from that screen; new keys prefer the macOS Keychain. Metal runtime path, Rosetta-aware packaging, and Darwin TMPDIR redaction land with the version bump. --- CHANGELOG.md | 8 + Cargo.lock | 4 +- Cargo.toml | 2 +- docs/MACOS_PORT_ESTIMATE.md | 20 +- docs/RELEASE_NOTES.md | 12 + native/core/src/keyring.rs | 9 +- native/core/src/local_engine.rs | 33 + native/core/src/openrouter.rs | 7 +- native/core/src/redaction.rs | 47 +- native/core/src/service/accounts.rs | 13 + native/core/src/service/inspection.rs | 98 +++ native/core/src/service/model_catalog.rs | 10 + .../com.shadowfetch.shadowcode.appdata.xml | 3 + scripts/build-llama.cpp.sh | 15 + scripts/build-macos-app.sh | 9 + src-tauri/tauri.conf.json | 2 +- ui/e2e/fakeBackend.ts | 23 +- ui/e2e/firstRun.spec.ts | 59 +- ui/package-lock.json | 4 +- ui/package.json | 2 +- ui/src/api.ts | 7 +- ui/src/components.css | 56 ++ ui/src/components/ChangesTab.tsx | 8 + ui/src/components/ComposerControls.tsx | 4 +- ui/src/components/Drawer.tsx | 1 + ui/src/components/ModelSetup.test.tsx | 80 +-- ui/src/components/ModelSetup.tsx | 63 +- ui/src/components/Onboarding.tsx | 101 +--- ui/src/components/ProviderConnect.test.tsx | 69 +++ ui/src/components/ProviderConnect.tsx | 563 ++++++++++++++++++ ui/src/components/Sidebar.tsx | 19 +- ui/src/components/settings/AccountsPage.tsx | 9 +- ui/src/components/settings/EndpointsCard.tsx | 5 +- ui/src/components/settings/KeyStorage.tsx | 4 +- ui/src/components/shell/Stage.tsx | 1 + ui/src/components/shell/TopBar.tsx | 2 +- ui/src/lib/badges.test.ts | 4 + ui/src/lib/badges.ts | 10 + ui/src/lib/keyring.ts | 17 + ui/src/tasks.css | 28 + ui/src/workspace.css | 2 +- 41 files changed, 1168 insertions(+), 265 deletions(-) create mode 100644 ui/src/components/ProviderConnect.test.tsx create mode 100644 ui/src/components/ProviderConnect.tsx create mode 100644 ui/src/lib/keyring.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6363ca..94aee1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## 2.4.0: Apple silicon, T3-style first run, easy sign-in + +- **Apple silicon.** Packaging prefers native arm64 even when the shell is under Rosetta. llama.cpp Metal builds require `default.metallib` and set `GGML_METAL_PATH_RESOURCES` at runtime. `shadowcode doctor` reports Apple silicon vs Intel vs Rosetta and whether the Metal shaders are present. +- **TMPDIR redaction.** Darwin `/var/folders///T` names are no longer treated as high-entropy secrets, so macOS command cards stay readable. +- **First useful action is one screen.** Welcome is Open project (ask-first approvals by default). Sign-in, API keys and a free local download live on the empty conversation, not a second blocking wizard. +- **Easy sign-in.** Connect Codex, Claude Code, Cursor, Antigravity (Install & connect) and Grok from that same picker. Paste an OpenRouter, xAI or OpenAI key inline; new keys move to the macOS Keychain (or Linux desktop keyring) when one is available. Settings › Accounts remains the place to switch, disconnect and add other endpoints. +- **Chat-first chrome.** Sidebar threads show Working / Done / Needs approval. Permission mode short label is Allow edits. Changes includes Open pull request… into the Git tab. + ## 2.3.3: Desktop reliability Extends the existing Linux release signing key trust interval through 2.3.3. The tagged 2.3.2 candidate was not published because its trust interval ended at 2.3.1. diff --git a/Cargo.lock b/Cargo.lock index 362781e3..b3dc851c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4512,7 +4512,7 @@ dependencies = [ [[package]] name = "shadowcode-core" -version = "2.3.3" +version = "2.4.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4575,7 +4575,7 @@ dependencies = [ [[package]] name = "shadowcode-desktop" -version = "2.3.3" +version = "2.4.0" dependencies = [ "anyhow", "gdk", diff --git a/Cargo.toml b/Cargo.toml index 979edc6a..4392ab2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor/glib-0.18.5"] resolver = "2" [workspace.package] -version = "2.3.3" +version = "2.4.0" edition = "2021" rust-version = "1.95" license = "Apache-2.0" diff --git a/docs/MACOS_PORT_ESTIMATE.md b/docs/MACOS_PORT_ESTIMATE.md index 26c1a1e7..59a66ad1 100644 --- a/docs/MACOS_PORT_ESTIMATE.md +++ b/docs/MACOS_PORT_ESTIMATE.md @@ -107,13 +107,19 @@ Still open: - **Publisher signatures for the macOS images.** They are checked against the release's SHA-256 file over HTTPS, not signed with the release key: that needs macOS targets in `release/trust/policy` and the signing tools. -- **Apple silicon** builds (`aarch64-apple-darwin`) are untested: there is - no arm64 Mac here. The runtime build already has the M1–M4 CPU variants. -- **Real model runs** on the Mac: the runtime loads and sees the GPU, but no - GGUF was available to generate with. -- Redaction's high-entropy rule masks macOS's per-user temporary folder - (`/var/folders//T/…`) as a secret. Safe but noisy; a path-aware - rule must not let base64 secrets with slashes through. +- **Notarization.** Disk images stay ad-hoc signed (GitHub is the channel). +- **Real model runs** on Apple silicon: doctor and the Metal runtime path + are wired; generating with a GGUF on this Mac still needs a model file. + +Closed in 2.4.0 (this Apple silicon Mac Mini): + +- Native arm64 packaging even when the shell is under Rosetta + (`hw.optional.arm64`); Intel builds remain for Intel Macs. +- Metal: `GGML_METAL_PATH_RESOURCES` points at `default.metallib` next to + llama-server; doctor reports processor and shader presence. +- Redaction skips Darwin `/var/folders///T` names so TMPDIR + paths are not masked as secrets. +- API keys prefer the login Keychain when the UI sends `prefer_keyring`. ## Work items diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 42976401..0a19bc75 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,3 +1,15 @@ +# ShadowCode 2.4.0 (candidate) + +Not yet tagged. This candidate is the first Apple silicon verification and fine-tune pass, plus T3-style first-run and one-click provider sign-in. Do not overwrite published 2.3.3 assets. Suggested tag: `v2.4.0` after CI and a real-model Metal smoke if a GGUF is available. + +**Apple silicon.** Native arm64 packaging when `hw.optional.arm64=1` even if `uname -m` is `x86_64` under Rosetta. Metal llama.cpp sets `GGML_METAL_PATH_RESOURCES` to the directory that holds `default.metallib`. Doctor reports processor and Metal shader presence. Darwin temp-folder names are no longer redacted as secrets. + +**T3-style first run.** Open project lands in chat with the composer ready. Connect, paste an API key, or download a free local model on that same screen. Sidebar states are Working / Done / Needs approval. Git: commit from Changes, then Open pull request…. + +**Easy sign-in.** Codex, Claude, Cursor, Antigravity and Grok each have Connect (Antigravity can Install & connect). API keys: OpenRouter, xAI (Grok) and OpenAI on the same picker; stored in Keychain on Mac when available. + +**Still blocked.** Vendor CLIs that are not installed still show Not installed (except Antigravity, which can download the agent). Cursor/Claude/Codex have no password form — they use each vendor's own browser sign-in. macOS images remain ad-hoc signed, not notarized. Real GGUF generation on this Mac is not part of this candidate unless a model is present. + # ShadowCode 2.3.3 Linux x64 and ARM64 packages are published and authenticated with the existing release signing key. Windows, macOS and Flatpak packages are also published. Version 2.3.2 was not published because its signing policy did not authorize that version; its tag remains unchanged. Version 2.3.3 extends the existing key's trust interval and adds a release preflight for policy mismatches. diff --git a/native/core/src/keyring.rs b/native/core/src/keyring.rs index cc806b5b..0da4b69b 100644 --- a/native/core/src/keyring.rs +++ b/native/core/src/keyring.rs @@ -1,10 +1,11 @@ //! API keys in the desktop keyring, with `secrets.env` as the fallback. //! //! The keyring is the freedesktop Secret Service (GNOME Keyring, KWallet, -//! KeePassXC …) on the session D-Bus, or the login Keychain on macOS. A key moves there only when the user -//! asks (Settings › Accounts), and moves back the same way; the move writes -//! and reads back the new place before removing the old one, so a key is -//! never lost. `config/keyring.json` (mode 600) lists which keys live in the +//! KeePassXC …) on the session D-Bus, or the login Keychain on macOS. A key +//! moves there when the user asks (Settings › Accounts) or when the Mac UI +//! saves an API key with `prefer_keyring`; it moves back the same way. The +//! move writes and reads back the new place before removing the old one, so +//! a key is never lost. `config/keyring.json` (mode 600) lists which keys live in the //! keyring; everything else stays in `secrets.env`, which is what headless //! machines, SSH sessions and `shadowcode serve` use. Saving or removing a //! key that lives in the keyring from a session that cannot reach one puts diff --git a/native/core/src/local_engine.rs b/native/core/src/local_engine.rs index 0fc98f56..ee67df8d 100644 --- a/native/core/src/local_engine.rs +++ b/native/core/src/local_engine.rs @@ -531,6 +531,14 @@ pub fn runtime_env(binary: &Path) -> Vec<(String, std::ffi::OsString)> { #[cfg(unix)] if let Some(dir) = binary.parent() { env.push(("LD_LIBRARY_PATH".into(), dir.as_os_str().to_owned())); + // llama.cpp loads default.metallib from this directory (compiled at + // build time). Without it, first Metal use compiles shaders at run + // time or fails outside a GUI session. + #[cfg(target_os = "macos")] + env.push(( + "GGML_METAL_PATH_RESOURCES".into(), + dir.as_os_str().to_owned(), + )); } #[cfg(not(unix))] let _ = binary; @@ -2239,6 +2247,12 @@ mod tests { ); assert_eq!(metal[0].name, "AMD Radeon Pro 555X"); assert_eq!(backend_of(&metal), "metal"); + // Apple silicon (M-series reports as MTL0: Apple M…). + let apple = parse_devices( + "Available devices:\n MTL0: Apple M4 (21845 MiB, 19660 MiB free)\n", + ); + assert_eq!(apple[0].name, "Apple M4"); + assert_eq!(backend_of(&apple), "metal"); let (v, c) = parse_version("x\nversion: 0.4.1-dev (build 1, commit 18f9f7bef)\nbuilt with GNU") .unwrap(); @@ -2298,4 +2312,23 @@ mod tests { assert!(readded.excluded.is_empty()); assert!(remove(&config, "/nowhere/x.gguf").is_err()); } + + #[test] + fn runtime_env_points_metal_shaders_at_the_binary_on_macos() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("llama-server"); + fs::write(&binary, b"x").unwrap(); + let env = runtime_env(&binary); + #[cfg(target_os = "macos")] + { + let metal = env + .iter() + .find(|(name, _)| name == "GGML_METAL_PATH_RESOURCES") + .map(|(_, value)| value.clone()); + assert_eq!(metal.as_deref(), Some(dir.path().as_os_str())); + } + #[cfg(not(target_os = "macos"))] + assert!(!env.iter().any(|(name, _)| name == "GGML_METAL_PATH_RESOURCES")); + let _ = binary; + } } diff --git a/native/core/src/openrouter.rs b/native/core/src/openrouter.rs index 635df81e..23982d74 100644 --- a/native/core/src/openrouter.rs +++ b/native/core/src/openrouter.rs @@ -5,9 +5,10 @@ //! //! - The model list comes from the public `GET /api/v1/models` and is cached //! in the state directory; offline mode never fetches it. -//! - The key lives in the profile secrets file (`OPENROUTER_API_KEY`), is -//! checked with `GET /api/v1/key` before it is saved, and is never returned -//! to the window. +//! - The key lives in the profile secrets file (`OPENROUTER_API_KEY`) or, +//! when the UI sends `prefer_keyring`, in the macOS Keychain / desktop +//! keyring. It is checked with `GET /api/v1/key` before it is saved, and is +//! never returned to the window. //! - Turns run on ShadowCode's own agent loop against the OpenAI-compatible //! `POST /api/v1/chat/completions`, so ShadowCode's permissions, approvals, //! checkpoints and review all apply. diff --git a/native/core/src/redaction.rs b/native/core/src/redaction.rs index 3c32468c..753b0c27 100644 --- a/native/core/src/redaction.rs +++ b/native/core/src/redaction.rs @@ -104,12 +104,15 @@ fn high_entropy_tokens(text: &str) -> Vec<(usize, usize)> { for found in re.find_iter(text) { let run = found.as_str(); if let Some(segments) = path_segments(run) { - spans.extend(segments.filter(|(_, segment)| looks_random(segment)).map( - |(offset, segment)| { - let start = found.start() + offset; - (start, start + segment.len()) - }, - )); + let listed: Vec<(usize, &str)> = segments.collect(); + let skip = darwin_temp_folder_offsets(&listed); + spans.extend(listed.into_iter().filter_map(|(offset, segment)| { + if skip.contains(&offset) || !looks_random(segment) { + return None; + } + let start = found.start() + offset; + Some((start, start + segment.len())) + })); } else if looks_random(run) { spans.push((found.start(), found.end())); } @@ -117,6 +120,29 @@ fn high_entropy_tokens(text: &str) -> Vec<(usize, usize)> { spans } +/// Offsets of Darwin per-user temp folder names (`/var/folders///T`). +/// Those names are high-entropy by design (mixed letters, digits, `_`, often +/// 32+ characters) and are not secrets; masking them hid every macOS command +/// that ran from `$TMPDIR`. +fn darwin_temp_folder_offsets(segments: &[(usize, &str)]) -> Vec { + let names: Vec<&str> = segments.iter().map(|(_, name)| *name).collect(); + for i in 0..names.len() { + if names[i] != "var" || names.get(i + 1) != Some(&"folders") { + continue; + } + if names.get(i + 4) != Some(&"T") { + continue; + } + return segments + .get(i + 2) + .into_iter() + .chain(segments.get(i + 3)) + .map(|(offset, _)| *offset) + .collect(); + } + Vec::new() +} + /// The segments (with their offsets) of an absolute path under a well-known /// root, or `None` for anything else. fn path_segments(run: &str) -> Option> { @@ -647,6 +673,8 @@ mod tests { for path in [ "/Volumes/NVME1TB/shadowcode-macos/task-demo", "/private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpAb3dEf/project", + // Real Darwin TMPDIR names are often 32+ mixed alnum/`_` chars. + "/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000gn/T/.tmpAb3dEf/project", "/Users/Robert/Projects/ShadowCode2026/native/core/src/redaction.rs", "/home/dev/work/Project-2026_v2/src/main.rs", "file:///private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpX1/team.git", @@ -654,6 +682,13 @@ mod tests { let result = redact_text(&format!("cwd: {path}")); assert!(!result.redacted, "{path} -> {}", result.text); } + let noisy = "/var/folders/8q/k1q2r3s4t5u6v7w8x9y0z1a2b3c4d5__/T/shadowcode-cmd.log"; + let result = redact_text(&format!("ran: cat {noisy}")); + assert!( + !result.redacted && result.text.contains("k1q2r3s4t5u6v7w8x9y0z1a2b3c4d5__"), + "{}", + result.text + ); // A secret that is a whole path segment is still hidden, the rest kept. let secret = "Zx9Kq2Lm7Pw4Rt6Yv8Nb3Hc5Jd1Fg0Se7Ua2Qi"; let result = redact_text(&format!("/Users/dev/{secret}/notes.txt")); diff --git a/native/core/src/service/accounts.rs b/native/core/src/service/accounts.rs index 35156610..415e4fbc 100644 --- a/native/core/src/service/accounts.rs +++ b/native/core/src/service/accounts.rs @@ -26,6 +26,19 @@ impl Service { if !key.trim().is_empty() { // The first save also fetches the model list. let _ = crate::openrouter::refresh(self.engine.paths(), false).await; + // The Mac UI asks for Keychain (or the Linux desktop + // keyring) so a pasted key is not left in secrets.env. + if call.body["prefer_keyring"].as_bool() == Some(true) { + let paths = self.engine.paths(); + let name = crate::openrouter::KEY_ENV; + if crate::keyring::availability().is_ok() + && !crate::keyring::listed(paths).contains(name) + { + if let Err(error) = crate::keyring::move_in(paths, name) { + tracing::warn!("keyring.prefer_openrouter error={error:#}"); + } + } + } } return Ok( crate::openrouter::status(self.engine.paths(), cfg.offline(), true).await, diff --git a/native/core/src/service/inspection.rs b/native/core/src/service/inspection.rs index 4edf7a66..26feb90c 100644 --- a/native/core/src/service/inspection.rs +++ b/native/core/src/service/inspection.rs @@ -16,6 +16,103 @@ fn owner_only(m: &std::fs::Metadata) -> bool { } } +/// Whether this process is an Intel binary translated on Apple silicon. +#[cfg(target_os = "macos")] +fn proc_translated() -> bool { + let mut value: i32 = 0; + let mut size = std::mem::size_of_val(&value); + let rc = unsafe { + libc::sysctlbyname( + b"sysctl.proc_translated\0".as_ptr().cast(), + std::ptr::from_mut(&mut value).cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + rc == 0 && value == 1 +} + +fn host_platform_checks() -> Vec { + let arch = std::env::consts::ARCH; + #[cfg(target_os = "macos")] + { + let translated = proc_translated(); + let detail = if arch == "aarch64" && !translated { + format!("Apple silicon ({arch}); native arm64, not Rosetta") + } else if translated { + format!( + "Running under Rosetta as {arch}; use the Apple silicon (aarch64) build for Metal" + ) + } else { + format!("Intel Mac ({arch})") + }; + let mut checks = vec![check( + "host-arch", + if translated { "warn" } else { "pass" }, + "Processor", + detail, + "Install ShadowCode_VERSION_aarch64.dmg on Apple silicon.", + )]; + checks.extend(macos_metal_checks()); + checks + } + #[cfg(not(target_os = "macos"))] + { + vec![check( + "host-arch", + "info", + "Processor", + format!("{}/{arch}", std::env::consts::OS), + "", + )] + } +} + +#[cfg(target_os = "macos")] +fn macos_metal_checks() -> Vec { + let runtime = crate::local_engine::runtime(""); + let metal_lib = runtime + .path + .as_ref() + .and_then(|path| path.parent()) + .map(|dir| dir.join("default.metallib")); + let has_lib = metal_lib.as_ref().is_some_and(|path| path.is_file()); + let backend = runtime.backend(); + vec![ + check( + "local-runtime", + match runtime.state { + "ready" => "pass", + "setup_required" => "info", + _ => "warn", + }, + "Local llama.cpp runtime", + runtime.detail.clone(), + "Install or rebuild the macOS app so the Metal llama.cpp runtime is bundled.", + ), + check( + "metal", + if runtime.state != "ready" { + "info" + } else if backend == "metal" || has_lib { + "pass" + } else { + "warn" + }, + "Metal GPU", + if has_lib { + format!("default.metallib present; backend {backend}") + } else if runtime.state == "ready" { + format!("No default.metallib next to llama-server; backend {backend}") + } else { + "Runtime not installed yet; Metal shaders ship as default.metallib beside llama-server.".into() + }, + "Rebuild with scripts/build-llama.cpp.sh so Metal shaders are compiled at build time.", + ), + ] +} + fn check(id: &str, status: &str, label: &str, detail: impl Into, fix: &str) -> Value { json!({"id":id,"status":status,"ok":status=="pass","label":label,"detail":detail.into(),"fix":if status=="pass"{""}else{fix}}) } @@ -355,6 +452,7 @@ impl Service { .unwrap_or("not an OS sandbox"), "", )); + checks.extend(host_platform_checks()); checks.extend(crate::sandbox::doctor_checks()); checks.push(self.rules_check()); for mut check in crate::cli_agent::doctor::checks(&cfg.cli_agents).await { diff --git a/native/core/src/service/model_catalog.rs b/native/core/src/service/model_catalog.rs index 4b63c64b..0035a5a4 100644 --- a/native/core/src/service/model_catalog.rs +++ b/native/core/src/service/model_catalog.rs @@ -443,6 +443,16 @@ impl Service { let key = body["api_key"].as_str().unwrap_or("").trim(); if !key.is_empty() { config::set_secret(paths, &key_env, key)?; + // The Mac UI asks for Keychain (or the Linux desktop keyring) + // so a pasted endpoint key is not left in secrets.env. + if body["prefer_keyring"].as_bool() == Some(true) + && crate::keyring::availability().is_ok() + && !crate::keyring::listed(paths).contains(&key_env) + { + if let Err(error) = crate::keyring::move_in(paths, &key_env) { + tracing::warn!("keyring.prefer_endpoint error={error:#}"); + } + } } // With a key the server needs one: the row asks for it if it is // removed later. Without one (Ollama, a server on the LAN) it never does. diff --git a/packaging/com.shadowfetch.shadowcode.appdata.xml b/packaging/com.shadowfetch.shadowcode.appdata.xml index 5aaa21ad..e8487fe2 100644 --- a/packaging/com.shadowfetch.shadowcode.appdata.xml +++ b/packaging/com.shadowfetch.shadowcode.appdata.xml @@ -29,6 +29,9 @@ shadowcode + +

Apple silicon Metal runtime path, T3-style first-run (open a project then chat), and one-click Connect for Codex, Claude, Cursor, Antigravity, Grok and API keys stored in the macOS Keychain.

+

Improves AppImage startup on rolling Linux desktops, model-picker focus, and platform keyboard hints.

diff --git a/scripts/build-llama.cpp.sh b/scripts/build-llama.cpp.sh index 5465f29f..9e32185e 100755 --- a/scripts/build-llama.cpp.sh +++ b/scripts/build-llama.cpp.sh @@ -222,6 +222,14 @@ if [[ "$DARWIN" == "1" ]]; then VULKAN_FLAGS=(-DGGML_VULKAN=OFF -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=OFF -DGGML_BLAS=OFF) BACKEND="metal+cpu" RPATH='@loader_path' + EXTRA_FLAGS+=(-DCMAKE_OSX_DEPLOYMENT_TARGET=12.0) + HOST_ARCH="$(uname -m)" + if [[ "$HOST_ARCH" == x86_64 && "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" == 1 ]]; then + echo "This shell is running under Rosetta. Building the native Apple silicon (arm64) Metal runtime." >&2 + EXTRA_FLAGS+=(-DCMAKE_OSX_ARCHITECTURES=arm64) + HOST_ARCH=arm64 + fi + printf 'llama.cpp Metal runtime for %s\n' "$([[ "$HOST_ARCH" == arm64 ]] && echo 'Apple silicon' || echo Intel)" fi if [[ "$WINDOWS" == "1" ]]; then # The Vulkan SDK brings the headers, loader library and glslc together. @@ -355,10 +363,17 @@ install_runtime() { rm -rf "$dest.old" } install_runtime "$OUT_REPO/bin" +if [[ "$DARWIN" == "1" && ! -f "$OUT_REPO/bin/default.metallib" ]]; then + echo "Metal shader library missing after the runtime build: $OUT_REPO/bin/default.metallib" >&2 + exit 1 +fi if [[ "$USER_INSTALL" == "1" ]]; then install_runtime "$OUT_USER" fi "$OUT_REPO/bin/llama-server$EXE" --version +if [[ "$DARWIN" == "1" ]]; then + "$OUT_REPO/bin/llama-server$EXE" --list-devices || true +fi printf 'Built managed llama.cpp %s (%s) -> %s\n' "$COMMIT" "$BACKEND" "$OUT_REPO/bin" if [[ "$USER_INSTALL" == "1" ]]; then printf 'Installed managed llama.cpp -> %s\n' "$OUT_USER" diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 0d76b3db..d057457e 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -16,6 +16,15 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" [[ "$(uname -s)" == Darwin ]] || { echo "Build the macOS app on macOS." >&2; exit 1; } +HOST_ARCH="$(uname -m)" +# A shell under Rosetta reports x86_64 on Apple silicon; still build arm64. +if [[ "$HOST_ARCH" == x86_64 && "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" == 1 ]]; then + echo "This shell is running under Rosetta. Building the native Apple silicon (arm64) app." >&2 + HOST_ARCH=arm64 + export CARGO_BUILD_TARGET="${CARGO_BUILD_TARGET:-aarch64-apple-darwin}" +fi +printf 'Building ShadowCode.app for %s (%s)\n' \ + "$([[ "$HOST_ARCH" == arm64 ]] && echo 'Apple silicon' || echo Intel)" "$HOST_ARCH" RUNTIME="$ROOT/packaging/llama.cpp/bin" field() { [[ -f "$1" ]] && awk -F= -v key="$2" '$1 == key { print substr($0, length(key) + 2); exit }' "$1"; } PINNED="$(field "$ROOT/tools/llama.cpp.pin" commit)" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1b4d0452..cba1a426 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShadowCode", - "version": "2.3.3", + "version": "2.4.0", "identifier": "com.shadowfetch.shadowcode", "build": { "beforeDevCommand": { diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index 1ff7eef7..294157c4 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -1098,6 +1098,14 @@ export function installFakeBackend(options: FakeOptions = {}) { key: "sk-example-good", models: ["example-coder", "example-mini"], }, + "https://api.x.ai/v1": { + key: "xai-example-good", + models: ["grok-4"], + }, + "https://api.openai.com/v1": { + key: "sk-example-good", + models: ["gpt-4.1"], + }, }; const endpointLabels: Record = { ollama: "Ollama", @@ -3274,6 +3282,13 @@ export function installFakeBackend(options: FakeOptions = {}) { is_free_tier: false, }; state.openrouter.fetched_at = now(); + if (!state.keyNames.includes("OPENROUTER_API_KEY")) + state.keyNames.push("OPENROUTER_API_KEY"); + if (body?.prefer_keyring === true) { + state.keysInKeyring = [ + ...new Set([...state.keysInKeyring, "OPENROUTER_API_KEY"]), + ]; + } return openrouterStatus(); } if (path === "/api/providers/models" && method === "POST") { @@ -3330,7 +3345,13 @@ export function installFakeBackend(options: FakeOptions = {}) { if (!names.length) throw new Error(JSON.stringify({ error: "Choose at least one model" })); const typed = String(body?.api_key || "").trim(); - if (typed) state.endpointKeys[keyName] = typed; + if (typed) { + state.endpointKeys[keyName] = typed; + if (!state.keyNames.includes(keyName)) state.keyNames.push(keyName); + if (body?.prefer_keyring === true) { + state.keysInKeyring = [...new Set([...state.keysInKeyring, keyName])]; + } + } const added = names.map((name) => { const id = `model:${provider}:${endpoint}:${name}`; const model = { diff --git a/ui/e2e/firstRun.spec.ts b/ui/e2e/firstRun.spec.ts index 91bd0e72..377b3343 100644 --- a/ui/e2e/firstRun.spec.ts +++ b/ui/e2e/firstRun.spec.ts @@ -1,10 +1,10 @@ import { test, expect, type Page } from "@playwright/test"; import { installFakeBackend, type FakeOptions } from "./fakeBackend"; -// A new user with no subscription, no API key and no local model: the first -// run offers a free model for this computer, an OpenRouter key or a -// subscription; the download runs in the window and the model is selected -// when it is ready. Downloads and progress come from the fake engine. +// A new user with no subscription, no API key and no local model: Open +// project lands in chat. The empty conversation offers Connect for each +// vendor, an API key field, and a free local download. The download runs +// in the window and the model is selected when it is ready. async function start(page: Page, options: FakeOptions = {}) { const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); @@ -35,43 +35,42 @@ const downloadPosts = (page: Page) => ), ); -async function trustAndOpen(page: Page) { +async function openProject(page: Page) { const welcome = page.getByRole("dialog", { name: "Welcome to ShadowCode" }); await expect(welcome.getByLabel("Project folder")).toHaveValue("/work/demo"); - await welcome.getByRole("button", { name: "Trust and open" }).click(); - const step = page.getByRole("dialog", { name: "Choose a model" }); + await welcome.getByRole("button", { name: "Open project" }).click(); + await expect(welcome).toBeHidden(); await expect( - step.getByRole("heading", { name: "Choose how ShadowCode thinks" }), + page.getByRole("textbox", { name: "Message ShadowCode" }), ).toBeVisible(); - return step; } test("no accounts: download the recommended free model and start with it", async ({ page, }) => { await start(page, { downloadSteps: 3 }); - const step = await trustAndOpen(page); - const download = step.getByRole("radio", { + await openProject(page); + const download = page.getByRole("radio", { name: /Download a free model to run on this computer/, }); await expect(download).toBeChecked(); - await expect(step).toContainText( + await expect(page).toContainText( "Gemma 4 E4B by Google: 4.8 GB, about 14 minutes on a 50 Mbit/s connection. Runs on your graphics card.", ); - await expect(step).toContainText( + await expect(page).toContainText( "Picked for this computer: Intel Arc A750 (8.0 GB) · 16 GB memory.", ); await expect( - step.getByRole("radio", { name: /Use an OpenRouter key/ }), + page.getByRole("button", { name: "Connect Codex" }), ).toBeVisible(); await expect( - step.getByRole("radio", { name: /Sign in to a subscription/ }), + page.getByRole("button", { name: "Connect Claude Code" }), ).toBeVisible(); + await expect(page.getByLabelText("OpenRouter API key")).toBeVisible(); await page.screenshot({ path: "test-results/first-run-model-step.png" }); expect(await downloadPosts(page)).toEqual([]); - await step.getByRole("button", { name: "Download Gemma 4 E4B" }).click(); - await expect(step).toBeHidden(); + await page.getByRole("button", { name: "Download Gemma 4 E4B" }).click(); // The empty conversation follows the download… await expect( page.getByText( @@ -103,31 +102,29 @@ test("no accounts: download the recommended free model and start with it", async ).toBeVisible(); }); -test("no accounts: OpenRouter and subscriptions go to Accounts; the welcome keeps the choices", async ({ +test("no accounts: sign-in and API keys stay on the empty conversation", async ({ page, }) => { await start(page); - const step = await trustAndOpen(page); - await step.getByRole("radio", { name: /Use an OpenRouter key/ }).click(); - await step.getByRole("button", { name: "Add an OpenRouter key" }).click(); - const settings = page.getByRole("dialog", { name: "Settings" }); - await expect(settings.getByLabel("OpenRouter API key")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(settings).toBeHidden(); - // Still no model: the empty conversation offers the same three choices. + await openProject(page); await expect( - page.getByText( - "ShadowCode needs a model to work. Pick one of these to start.", - ), + page.getByText("Open a project, pick an agent, send a message."), ).toBeVisible(); - await page.getByRole("radio", { name: /Sign in to a subscription/ }).click(); - await page.getByRole("button", { name: "Choose a subscription" }).click(); + await expect( + page.getByRole("button", { name: "Connect Codex" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Settings › Accounts" }).click(); + const settings = page.getByRole("dialog", { name: "Settings" }); await expect(settings.getByRole("article", { name: "Codex" })).toBeVisible(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "See other free models" }).click(); await expect( settings.getByRole("heading", { name: "Download a free model" }), ).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByLabelText("OpenRouter API key").fill("sk-or-valid"); + await page.getByRole("button", { name: "Save key" }).click(); + await expect(page.getByText(/OpenRouter API key saved/)).toBeVisible(); expect(await downloadPosts(page)).toEqual([]); }); diff --git a/ui/package-lock.json b/ui/package-lock.json index 5f15f353..b9484216 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "shadow-agent-ui", - "version": "2.3.3", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shadow-agent-ui", - "version": "2.3.3", + "version": "2.4.0", "license": "Apache-2.0", "dependencies": { "@codemirror/commands": "6.11.1", diff --git a/ui/package.json b/ui/package.json index 50c8834e..c6318714 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "shadow-agent-ui", - "version": "2.3.3", + "version": "2.4.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/ui/src/api.ts b/ui/src/api.ts index 9213d34b..d8318d05 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1882,7 +1882,10 @@ export const api = { const api_key = key.trim(); // An empty body removes the key; never let a blank field do that. if (!api_key) throw new Error("Paste an OpenRouter API key first."); - return send("/api/openrouter/key", "POST", { api_key }); + return send("/api/openrouter/key", "POST", { + api_key, + prefer_keyring: true, + }); }, removeOpenrouterKey: () => send("/api/openrouter/key", "POST", { api_key: "" }), @@ -1898,7 +1901,7 @@ export const api = { send<{ ok: boolean; models: { default: string; name: string }[] }>( "/api/models/endpoints", "POST", - request, + { ...request, prefer_keyring: true }, ), /** Removes a model added for an endpoint; its saved key stays. */ removeEndpointModel: (id: string) => diff --git a/ui/src/components.css b/ui/src/components.css index 250387c9..2c7bd6c8 100644 --- a/ui/src/components.css +++ b/ui/src/components.css @@ -81,6 +81,62 @@ button.link { margin: -2px 0 2px 32px; font-size: 12px; } + +/* First-run / empty chat: one-click vendor sign-in and API keys -------- */ +.provider-connect { + display: grid; + gap: 10px; + margin-bottom: 16px; +} +.provider-connect-lead { + margin: 0; + color: var(--text-2); + font-size: 13px; +} +.provider-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} +.provider-tile { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} +.provider-tile strong { + display: block; +} +.provider-tile small { + display: block; + color: var(--text-2); + font-size: 12px; +} +.provider-tile .login-panel { + grid-column: 1 / -1; +} +.openrouter-quick, +.api-key-quick { + display: grid; + gap: 6px; +} +.api-key-quick .field-row { + display: grid; + grid-template-columns: minmax(7rem, 10rem) 1fr auto; + gap: 8px; + align-items: center; +} +@media (max-width: 640px) { + .api-key-quick .field-row { + grid-template-columns: 1fr; + } +} .cap-badge.download-recommended { border-color: var(--accent); color: var(--accent); diff --git a/ui/src/components/ChangesTab.tsx b/ui/src/components/ChangesTab.tsx index 349cd555..dd463b1d 100644 --- a/ui/src/components/ChangesTab.tsx +++ b/ui/src/components/ChangesTab.tsx @@ -31,6 +31,7 @@ export function ChangesTab({ onAskAgent, memory, onMemory, + onOpenPr, }: { path: string; busy: boolean; @@ -38,6 +39,8 @@ export function ChangesTab({ onAskAgent?: (prompt: string) => void; memory: DrawerMemory; onMemory: DrawerMemoryUpdate; + /** Switch to the Git tab (commit, push, open a pull request). */ + onOpenPr?: () => void; }) { const [git, setGit] = useState<{ status: string; @@ -306,6 +309,11 @@ export function ChangesTab({ > Commit + {onOpenPr && ( + + )}
{git.log}
{guard.dialog} diff --git a/ui/src/components/ComposerControls.tsx b/ui/src/components/ComposerControls.tsx index 76d226af..d79c6466 100644 --- a/ui/src/components/ComposerControls.tsx +++ b/ui/src/components/ComposerControls.tsx @@ -18,8 +18,8 @@ const MODES: { { id: "allow_edits", label: "Allow project edits", - short: "Edits allowed", - hint: "File edits inside this project run without asking. Shell commands still ask.", + short: "Allow edits", + hint: "File edits inside this project run without asking (the fast path). Shell commands still ask.", }, ]; diff --git a/ui/src/components/Drawer.tsx b/ui/src/components/Drawer.tsx index 5097f06f..cde99001 100644 --- a/ui/src/components/Drawer.tsx +++ b/ui/src/components/Drawer.tsx @@ -268,6 +268,7 @@ export function Drawer({ onAskAgent={onAskAgent} memory={memory} onMemory={onMemory} + onOpenPr={() => onTab("git")} /> )} diff --git a/ui/src/components/ModelSetup.test.tsx b/ui/src/components/ModelSetup.test.tsx index cf647035..c661c4ee 100644 --- a/ui/src/components/ModelSetup.test.tsx +++ b/ui/src/components/ModelSetup.test.tsx @@ -75,36 +75,33 @@ it("preselects the recommended free model with its size, time and fit", async () expect(posts("/api/local-models/downloads/start")[0].body).toEqual({ id: "gemma-4-e4b", }); - // No select control: the picker stays the one place a model is chosen. - expect(document.querySelector("select")).toBeNull(); + // The only select is the API key provider; models stay in the picker. + expect(screen.getByRole("combobox", { name: "API key provider" })).toBeTruthy(); + expect(screen.queryAllByRole("combobox")).toHaveLength(1); }); -it("routes OpenRouter, subscriptions and other models to their pages", async () => { +it("offers Connect for each vendor, an API key field, and a local download", async () => { start(); - const onOpenRouter = vi.fn(); - const onSubscription = vi.fn(); const onBrowse = vi.fn(); - render( - , - ); + const onSubscription = vi.fn(); + render(); + expect( + await screen.findByRole("button", { name: "Connect Codex" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connect Claude Code" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connect Cursor" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connect Grok" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Install Antigravity and connect" }), + ).toBeTruthy(); + expect(screen.getByLabelText("OpenRouter API key")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Save key" })).toBeTruthy(); fireEvent.click( await screen.findByRole("button", { name: "See other free models" }), ); expect(onBrowse).toHaveBeenCalled(); - fireEvent.click(screen.getByRole("radio", { name: /Use an OpenRouter key/ })); - fireEvent.click( - screen.getByRole("button", { name: "Add an OpenRouter key" }), - ); - expect(onOpenRouter).toHaveBeenCalled(); fireEvent.click( - screen.getByRole("radio", { name: /Sign in to a subscription/ }), - ); - fireEvent.click( - screen.getByRole("button", { name: "Choose a subscription" }), + screen.getByRole("button", { name: "Settings › Accounts" }), ); expect(onSubscription).toHaveBeenCalled(); expect(posts("/api/local-models/downloads/start")).toHaveLength(0); @@ -181,7 +178,7 @@ it("a refused start (disk space) is shown and nothing is marked started", async vi.restoreAllMocks(); }); -it("onboarding: with no ready model, a second step offers the three choices", async () => { +it("onboarding: Open project starts the workspace without a second wizard", async () => { start(); const onDone = vi.fn(); render(); @@ -190,42 +187,13 @@ it("onboarding: with no ready model, a second step offers the three choices", as (screen.getByLabelText("Project folder") as HTMLInputElement).value, ).toBe("/work/demo"), ); - fireEvent.click(screen.getByRole("button", { name: "Trust and open" })); - const dialog = await screen.findByRole("dialog", { name: "Choose a model" }); - expect( - within(dialog).getByRole("heading", { - name: "Choose how ShadowCode thinks", - }), - ).toBeTruthy(); - expect(onDone).not.toHaveBeenCalled(); - expect(fake.state.onboarded).toBe(true); - fireEvent.click( - within(dialog).getByRole("radio", { name: /Sign in to a subscription/ }), - ); - fireEvent.click( - within(dialog).getByRole("button", { name: "Choose a subscription" }), - ); - expect(onDone).toHaveBeenCalledWith("subscription"); -}); - -it("onboarding: Download starts the recommended model and closes", async () => { - start(); - const onDone = vi.fn(); - render(); - await waitFor(() => - expect( - (screen.getByLabelText("Project folder") as HTMLInputElement).value, - ).toBe("/work/demo"), - ); - fireEvent.click(screen.getByRole("button", { name: "Trust and open" })); - fireEvent.click( - await screen.findByRole("button", { name: "Download Gemma 4 E4B" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Open project" })); await waitFor(() => expect(onDone).toHaveBeenCalledWith()); - expect(fake.state.downloads.models[1].state).toBe("downloading"); + expect(fake.state.onboarded).toBe(true); + expect(screen.queryByRole("dialog", { name: "Choose a model" })).toBeNull(); }); -it("onboarding: a ready model skips the model step", async () => { +it("onboarding: a ready model still only asks for the project folder", async () => { start({ firstRun: false, onboarding: true }); const onDone = vi.fn(); render(); @@ -234,7 +202,7 @@ it("onboarding: a ready model skips the model step", async () => { (screen.getByLabelText("Project folder") as HTMLInputElement).value, ).toBe("/work/demo"), ); - fireEvent.click(screen.getByRole("button", { name: "Trust and open" })); + fireEvent.click(screen.getByRole("button", { name: "Open project" })); await waitFor(() => expect(onDone).toHaveBeenCalledWith()); expect(screen.queryByRole("dialog", { name: "Choose a model" })).toBeNull(); expect(fake.log.some((r) => r.path === "/api/local-models/downloads")).toBe( diff --git a/ui/src/components/ModelSetup.tsx b/ui/src/components/ModelSetup.tsx index c6533b64..43d1a438 100644 --- a/ui/src/components/ModelSetup.tsx +++ b/ui/src/components/ModelSetup.tsx @@ -13,12 +13,12 @@ import { recommendedModel, } from "../lib/downloads"; import { DownloadRow } from "./ModelDownloads"; +import { ProviderConnect } from "./ProviderConnect"; export type SetupChoice = "download" | "openrouter" | "subscription"; -/** "No model yet": download a free model for this computer (recommended and - * preselected when one fits), use an OpenRouter key, or sign in to a - * subscription. Nothing downloads until the button is pressed. */ +/** "No model yet": sign in to a subscription, paste an API key, or download + * a free local model. Nothing downloads until the button is pressed. */ export function ModelSetup({ downloads, onStarted, @@ -26,6 +26,7 @@ export function ModelSetup({ onSubscription, onBrowse, onSkip, + onConnected, skipLabel = "Skip for now", }: { downloads: ModelDownloads; @@ -35,6 +36,8 @@ export function ModelSetup({ /** Settings › Local models: every free model, with sizes. */ onBrowse: () => void; onSkip?: () => void; + /** A subscription or API key became ready: reload the picker. */ + onConnected?: () => void; skipLabel?: string; }) { const uid = useId(); @@ -72,8 +75,12 @@ export function ModelSetup({ return (
+
- How should ShadowCode think? + Or run a free model on this computer
{onSkip && ( @@ -162,9 +139,10 @@ export function ModelSetup({ type="button" className="primary" disabled={ - !choice || + choice !== "download" || starting || - (choice === "download" && (!canDownload || !chosen)) + !canDownload || + !chosen } onClick={() => void go()} > @@ -176,11 +154,7 @@ export function ModelSetup({ : chosen.state === "paused" || chosen.state === "failed" ? `Resume ${chosen.name}` : `Download ${chosen.name}` - : choice === "openrouter" - ? "Add an OpenRouter key" - : choice === "subscription" - ? "Choose a subscription" - : "Continue"} + : "Continue"}
@@ -188,7 +162,7 @@ export function ModelSetup({ } /** The empty conversation when no model is selected: a download in - * progress, or (with nothing ready at all) the three ways to get a model, + * progress, or (with nothing ready at all) sign-in plus a free local model, * or the usual "Choose a model" when something is ready. */ export function ModelSetupPanel({ downloads, @@ -197,6 +171,7 @@ export function ModelSetupPanel({ onOpenRouter, onSubscription, onBrowse, + onConnected, }: { downloads: ModelDownloads; hasReadyModel: boolean; @@ -204,6 +179,7 @@ export function ModelSetupPanel({ onOpenRouter: () => void; onSubscription: () => void; onBrowse: () => void; + onConnected?: () => void; }) { const catalog = downloads.catalog; const active = catalog?.models.find(inProgress); @@ -248,13 +224,14 @@ export function ModelSetupPanel({ ); return (
-

ShadowCode needs a model to work. Pick one of these to start.

+

Open a project, pick an agent, send a message.

undefined} onOpenRouter={onOpenRouter} onSubscription={onSubscription} onBrowse={onBrowse} + onConnected={onConnected} />
); diff --git a/ui/src/components/Onboarding.tsx b/ui/src/components/Onboarding.tsx index de0730d8..5c2e93d5 100644 --- a/ui/src/components/Onboarding.tsx +++ b/ui/src/components/Onboarding.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from "react"; import { api, type PickerResponse } from "../api"; import { Dialog } from "./Dialog"; -import { ModelSetup } from "./ModelSetup"; -import { useModelDownloads } from "../hooks/useModelDownloads"; import { isReady } from "../lib/picker"; import { canPickFiles, pickDirectory } from "../lib/transport"; @@ -37,26 +35,19 @@ export async function anyModelReady(timeoutMs = READY_CHECK_MS) { ]); } -/** First run: choose the project folder, trust it and pick how the agent may - * act. If no model is ready yet (no account, no key, no local model), a - * second step offers a free model for this computer, an OpenRouter key or a - * subscription. The composer's picker stays the place to switch models. */ +/** First run: choose the project folder and start. Permission mode defaults + * to ask-first (change it in the composer). If no model is ready yet, the + * empty conversation offers sign-in, an API key, or a free local model. */ export function Onboarding({ onDone, }: { onDone: (next?: OnboardingNext) => void; }) { const [workspace, setWorkspace] = useState(""); - const [mode, setMode] = useState<"ask" | "allow_edits">("ask"); - const [step, setStep] = useState<"project" | "checking" | "model">("project"); const [state, setState] = useState<{ busy: boolean; error: string }>({ busy: false, error: "", }); - const downloads = useModelDownloads({ - enabled: step === "model", - onError: (error) => setState({ busy: false, error }), - }); useEffect(() => { api @@ -68,54 +59,21 @@ export function Onboarding({ async function start() { setState({ busy: true, error: "" }); try { - // No provider or model: the backend keeps its model configuration and - // the picker becomes the first selection point. await api.completeOnboarding({ workspace: workspace.trim(), permission_level: "workspace", - permission_mode: mode, + permission_mode: "ask", theme: "system", }); - await api.saveConfig({ permissions: { mode } }).catch(() => undefined); - setStep("checking"); - if (await anyModelReady().catch(() => false)) return onDone(); - setState({ busy: false, error: "" }); - setStep("model"); + await api + .saveConfig({ permissions: { mode: "ask" } }) + .catch(() => undefined); + onDone(); } catch (err) { - setStep("project"); setState({ busy: false, error: String(err) }); } } - if (step === "model") - return ( - undefined} - > - -

Choose how ShadowCode thinks

-

- ShadowCode needs an AI model to read and change code. No account is - needed for the first choice. -

- onDone()} - onOpenRouter={() => onDone("openrouter")} - onSubscription={() => onDone("subscription")} - onBrowse={() => onDone("local")} - onSkip={() => onDone()} - /> - {state.error && ( -

- {state.error} -

- )} -
- ); - return ( Welcome to ShadowCode

An open-source project by Shadowfetch

- Open a project to start. You choose a model for each conversation in the - composer. + Open a project, pick an agent, send a message. Ask-first approvals are + on; switch to allow-edits in the composer when you want fewer prompts.

@@ -158,38 +116,9 @@ export function Onboarding({ )}
-
- When the agent wants to change something - - -

- Starting trusts this folder: the agent may read it and, with the choice - above, change files inside it. + Opening trusts this folder: the agent may read it. File edits and + commands still wait for your approval until you change that.

{state.error && (

@@ -203,11 +132,7 @@ export function Onboarding({ disabled={state.busy || !workspace.trim()} onClick={() => void start()} > - {step === "checking" - ? "Checking for models…" - : state.busy - ? "Opening…" - : "Trust and open"} + {state.busy ? "Opening…" : "Open project"}

diff --git a/ui/src/components/ProviderConnect.test.tsx b/ui/src/components/ProviderConnect.test.tsx new file mode 100644 index 00000000..203a2251 --- /dev/null +++ b/ui/src/components/ProviderConnect.test.tsx @@ -0,0 +1,69 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { installFakeBackend } from "../../e2e/fakeBackend"; +import { ProviderConnect } from "./ProviderConnect"; + +let fake: ReturnType; +beforeEach(() => { + vi.stubEnv("VITE_SHADOW_TEST_TRANSPORT", "1"); + fake = installFakeBackend({ stepMs: 5, firstRun: true }); +}); +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + delete window.__SHADOW_TEST_TRANSPORT__; +}); + +it("connects Codex from the one-click picker", async () => { + const onReady = vi.fn(); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Connect Codex" })); + await waitFor(() => + expect( + fake.log.some( + (r) => r.method === "POST" && r.path === "/api/accounts/codex/connect", + ), + ).toBe(true), + ); +}); + +it("saves an OpenRouter key and asks the keyring to keep it", async () => { + const onReady = vi.fn(); + render(); + fireEvent.change(await screen.findByLabelText("OpenRouter API key"), { + target: { value: "sk-or-valid" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save key" })); + await waitFor(() => + expect(screen.getByText(/OpenRouter API key saved/)).toBeTruthy(), + ); + expect(onReady).toHaveBeenCalled(); + expect(fake.state.openrouter.key).toBe("sk-or-valid"); + expect(fake.state.keysInKeyring).toContain("OPENROUTER_API_KEY"); +}); + +it("saves an xAI key as a Grok endpoint", async () => { + const onReady = vi.fn(); + render(); + fireEvent.change(await screen.findByLabelText("API key provider"), { + target: { value: "grok" }, + }); + fireEvent.change(screen.getByLabelText("xAI (Grok) API key"), { + target: { value: "xai-example-good" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save key" })); + await waitFor(() => + expect( + screen.getByText(/xAI \(Grok\) key saved/), + ).toBeTruthy(), + ); + expect(onReady).toHaveBeenCalled(); + expect(fake.state.endpointKeys.XAI_API_KEY).toBe("xai-example-good"); + expect(fake.state.keysInKeyring).toContain("XAI_API_KEY"); +}); diff --git a/ui/src/components/ProviderConnect.tsx b/ui/src/components/ProviderConnect.tsx new file mode 100644 index 00000000..6a235ff0 --- /dev/null +++ b/ui/src/components/ProviderConnect.tsx @@ -0,0 +1,563 @@ +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type FormEvent, +} from "react"; +import { + api, + type AgentInstall, + type LoginProgress, + type OpenRouterStatus, + type VendorStatus, +} from "../api"; +import { preferKeyring } from "../lib/keyring"; +import { listen } from "../lib/transport"; +import { LoginLine } from "./settings/AccountsPage"; + +export const PROVIDERS = [ + { + id: "codex", + label: "Codex", + detail: "ChatGPT / Codex CLI", + }, + { + id: "claude", + label: "Claude Code", + detail: "Anthropic subscription", + }, + { + id: "cursor", + label: "Cursor", + detail: "Cursor agent CLI", + }, + { + id: "antigravity", + label: "Antigravity", + detail: "Google agent", + }, + { + id: "grok", + label: "Grok", + detail: "xAI Grok CLI", + }, +] as const; + +export type ProviderId = (typeof PROVIDERS)[number]["id"]; + +type Login = { + vendor: string; + attempt: number; + state: + | "running" + | "stopping" + | "done" + | "unconfirmed" + | "failed" + | "unsupported"; + lines: string[]; + detail?: string; +}; + +const vendorId = (key: string) => key.replace(/^cli[-:]/, ""); + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** One-click sign-in for Codex, Claude, Cursor, Antigravity, Grok, plus an + * OpenRouter API key field. Used on first run and the empty conversation so + * Settings is not required to start. */ +export function ProviderConnect({ + onReady, + onOpenSettings, +}: { + /** A vendor signed in or a key was saved: reload the picker. */ + onReady?: () => void; + onOpenSettings?: () => void; +}) { + const [vendors, setVendors] = useState | null>( + null, + ); + const [error, setError] = useState(""); + const [busy, setBusy] = useState>({}); + const [logins, setLogins] = useState>({}); + const loginSequence = useRef>({}); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const load = useCallback(async () => { + try { + const result = await api.accountsCached(); + if (!mounted.current) return; + const next: Record = {}; + for (const [key, value] of Object.entries(result.vendors || {})) + next[vendorId(key)] = value; + setVendors(next); + setError(""); + } catch (e) { + if (mounted.current) setError(message(e)); + } + }, []); + useEffect(() => { + void load(); + }, [load]); + + const runningKey = JSON.stringify( + Object.values(logins) + .filter( + (login) => login.state === "running" || login.state === "stopping", + ) + .map(({ vendor, attempt }) => ({ vendor, attempt })), + ); + useEffect(() => { + const running = JSON.parse(runningKey) as { + vendor: string; + attempt: number; + }[]; + if (!running.length) return; + let live = true; + async function read(vendor: string, attempt: number) { + if (!live) return; + try { + const progress: LoginProgress = await api.loginProgress(vendor); + if (!live || (loginSequence.current[vendor] || 0) !== attempt) return; + setLogins((current) => { + const login = current[vendor]; + if (!login || login.attempt !== attempt) return current; + return { + ...current, + [vendor]: { + ...login, + lines: progress.lines?.length ? progress.lines : login.lines, + state: progress.done + ? progress.done.ok + ? progress.done.availability === "ready" + ? "done" + : "unconfirmed" + : "failed" + : progress.cancellation_requested + ? "stopping" + : login.state, + detail: progress.done?.detail || login.detail, + }, + }; + }); + if (progress.done) { + void load(); + if (progress.done.ok && progress.done.availability === "ready") + onReady?.(); + } + } catch { + // Keep known instructions and try again. + } + } + const readAll = () => { + for (const { vendor, attempt } of running) void read(vendor, attempt); + }; + let stop: (() => void) | undefined; + void listen("shadowcode:events", readAll) + .then((unlisten) => { + if (live) stop = unlisten; + else unlisten(); + }) + .catch(() => undefined); + const timer = setInterval(readAll, 1500); + readAll(); + return () => { + live = false; + clearInterval(timer); + stop?.(); + }; + }, [runningKey, load, onReady]); + + async function connect(vendor: string) { + const attempt = (loginSequence.current[vendor] || 0) + 1; + loginSequence.current[vendor] = attempt; + setBusy((b) => ({ ...b, [vendor]: true })); + try { + const result = await api.connectAccount(vendor); + if (!mounted.current || attempt !== loginSequence.current[vendor]) return; + setLogins((current) => ({ + ...current, + [vendor]: { + vendor, + attempt, + state: result.state === "unsupported" ? "unsupported" : "running", + lines: result.lines || [], + detail: + result.state === "unsupported" + ? result.hint || result.note || "Sign in with the vendor's own app." + : result.note, + }, + })); + } catch (e) { + if (mounted.current && attempt === loginSequence.current[vendor]) + setError(message(e)); + } finally { + if (mounted.current) setBusy((b) => ({ ...b, [vendor]: false })); + } + } + + async function cancelLogin(vendor: string) { + const login = logins[vendor]; + if (!login || login.state !== "running") return; + try { + await api.cancelLogin(vendor); + } catch (e) { + setError(message(e)); + } + } + + async function installAgent() { + setBusy((b) => ({ ...b, antigravity: true })); + try { + await api.installAntigravity(); + await load(); + } catch (e) { + setError(message(e)); + } finally { + if (mounted.current) setBusy((b) => ({ ...b, antigravity: false })); + } + } + + return ( +
+

+ Sign in to a subscription you already pay for. ShadowCode opens the + vendor's own page and never asks for a password. +

+ {error && ( +

+ {error} +

+ )} +
    + {PROVIDERS.map((provider) => { + const status = vendors?.[provider.id]; + const ready = status?.state === "ready"; + const install = provider.id === "antigravity" ? status?.install : null; + const needsInstall = Boolean(install && !install.installed); + const setup = + status?.availability === "setup_required" || + status?.state === "not_installed"; + const login = logins[provider.id]; + const loginActive = + login?.state === "running" || login?.state === "stopping"; + return ( +
  • +
    + {status?.product || provider.label} + + {ready + ? status?.account?.email || "Connected" + : needsInstall + ? "Install once, then sign in with Google" + : setup + ? status?.fix || provider.detail + : provider.detail} + +
    + {needsInstall ? ( + void installAgent()} + /> + ) : ( + + )} + {login && ( + void cancelLogin(provider.id)} + /> + )} +
  • + ); + })} +
+ + {onOpenSettings && ( +

+ Usage, disconnect and extra API endpoints live in{" "} + + . +

+ )} +
+ ); +} + +function InstallButton({ + install, + busy, + onInstall, +}: { + install: AgentInstall; + busy: boolean; + onInstall: () => void; +}) { + if (install.busy) { + return ( + + ); + } + return ( + + ); +} + +function LoginStatus({ + login, + onCancel, +}: { + login: Login; + onCancel: () => void; +}) { + return ( +
+ {login.state === "running" && ( +

Finish signing in on the page the vendor opened.

+ )} + {login.state === "done" &&

Signed in.

} + {login.state === "failed" && ( +

{login.detail || "Sign-in did not finish."}

+ )} + {login.lines.length > 0 && ( +
+          {login.lines.map((line, i) => (
+            
+ +
+ ))} +
+ )} + {(login.state === "running" || login.state === "stopping") && ( + + )} +
+ ); +} + +const API_KEYS = [ + { + id: "openrouter", + label: "OpenRouter", + placeholder: "sk-or-…", + }, + { + id: "grok", + label: "xAI (Grok)", + placeholder: "xai-…", + provider: "grok" as const, + endpoint: "https://api.x.ai/v1", + }, + { + id: "openai", + label: "OpenAI", + placeholder: "sk-…", + provider: "openai_compatible" as const, + endpoint: "https://api.openai.com/v1", + }, +] as const; + +type ApiKeyKind = (typeof API_KEYS)[number]["id"]; + +function ApiKeyQuickAdd({ onReady }: { onReady?: () => void }) { + const uid = useId().replace(/:/g, ""); + const [kind, setKind] = useState("openrouter"); + const [status, setStatus] = useState(null); + const [key, setKey] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(""); + const chosen = API_KEYS.find((item) => item.id === kind)!; + + useEffect(() => { + let live = true; + api + .openrouterStatus() + .then((next) => live && setStatus(next)) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + + async function save(event: FormEvent) { + event.preventDefault(); + if (!key.trim() || busy) return; + setBusy(true); + setError(""); + try { + if (kind === "openrouter") { + const next = await api.setOpenrouterKey(key); + await preferKeyring("OPENROUTER_API_KEY"); + setStatus(next); + setSaved("OpenRouter API key saved."); + } else { + const direct = API_KEYS.find( + ( + item, + ): item is Extract<(typeof API_KEYS)[number], { provider: string }> => + item.id === kind && "provider" in item, + ); + if (!direct) throw new Error("Choose OpenRouter, xAI or OpenAI."); + const listed = await api.endpointModels({ + provider: direct.provider, + endpoint: direct.endpoint, + api_key: key.trim(), + }); + const models = listed.models.slice(0, 12).map((model) => model.id); + if (!models.length) + throw new Error("That server listed no models for this key."); + await api.addEndpointModels({ + provider: direct.provider, + endpoint: listed.endpoint || direct.endpoint, + api_key: key.trim(), + api_key_env: listed.api_key_env, + models, + }); + await preferKeyring(listed.api_key_env); + setSaved(`${direct.label} key saved. Models are in the picker.`); + } + setKey(""); + onReady?.(); + } catch (e) { + setError(message(e)); + } finally { + setBusy(false); + } + } + + if (kind === "openrouter" && status?.key_set && !saved) { + return ( +

+ OpenRouter API key saved + {status.key?.label ? ` (${status.key.label})` : ""}. +

+ ); + } + + return ( +
void save(e)}> + +
+ + { + setKey(e.target.value); + setSaved(""); + if (error) setError(""); + }} + /> + +
+

+ Stored on this computer (macOS Keychain when available). OpenRouter + covers Claude, GPT, Grok and others; xAI and OpenAI keys talk to those + APIs directly. +

+ {saved && ( +

+ {saved} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 6ae8e030..f2c75781 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -17,7 +17,12 @@ import { SquarePen, } from "lucide-react"; import { api, type Job, type Project, type Session } from "../api"; -import { BADGE_LABELS, projectOf, type Badge } from "../lib/badges"; +import { + BADGE_LABELS, + BADGE_SHORT, + projectOf, + type Badge, +} from "../lib/badges"; import { readPins, writePins } from "../lib/storage"; import { shortcutHint } from "../lib/shortcuts"; import { ConversationMenu, type ConversationAction } from "./ConversationMenu"; @@ -214,12 +219,14 @@ export function Sidebar({ ) : ( {title} )} - {badge === "unread" && ( + {badge && ( + className={`task-state task-state-${badge}`} + aria-hidden={badge !== "unread" ? true : undefined} + aria-label={badge === "unread" ? BADGE_LABELS.unread : undefined} + > + {BADGE_SHORT[badge]} + )}