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 (
@@ -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.